fix(launcher): Campaign LA LA3 narrow review fixes
This commit is contained in:
parent
26feba8186
commit
347a1a5d16
9 changed files with 495 additions and 145 deletions
|
|
@ -12,6 +12,9 @@
|
||||||
context. -->
|
context. -->
|
||||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
|
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
|
||||||
namespace AcDream.Launcher.Core.Launching;
|
namespace AcDream.Launcher.Core.Launching;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -10,19 +12,41 @@ public sealed class LauncherProcessSupervisor : IDisposable
|
||||||
{
|
{
|
||||||
private readonly ILauncherChildProcessFactory _factory;
|
private readonly ILauncherChildProcessFactory _factory;
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
|
private readonly Queue<LauncherSessionState> _pendingStateChanges = [];
|
||||||
private ILauncherChildProcess? _process;
|
private ILauncherChildProcess? _process;
|
||||||
|
private LauncherSessionState _state = LauncherSessionState.Starting;
|
||||||
|
private int? _exitCode;
|
||||||
|
private bool _publishingStateChanges;
|
||||||
|
|
||||||
public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
|
public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
|
||||||
{
|
{
|
||||||
_factory = factory ?? new SystemChildProcessFactory();
|
_factory = factory ?? new SystemChildProcessFactory();
|
||||||
}
|
}
|
||||||
|
|
||||||
public LauncherSessionState State { get; private set; } = LauncherSessionState.Starting;
|
public LauncherSessionState State
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Set once <see cref="State"/> reaches
|
/// <summary>Set once <see cref="State"/> reaches
|
||||||
/// <see cref="LauncherSessionState.Exited"/>; null before then.
|
/// <see cref="LauncherSessionState.Exited"/>; null before then.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? ExitCode { get; private set; }
|
public int? ExitCode
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _exitCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Fires on every <see cref="LauncherSessionState"/>
|
/// <summary>Fires on every <see cref="LauncherSessionState"/>
|
||||||
/// transition, in order.</summary>
|
/// transition, in order.</summary>
|
||||||
|
|
@ -148,14 +172,15 @@ public sealed class LauncherProcessSupervisor : IDisposable
|
||||||
|
|
||||||
private void OnProcessExited(object? sender, EventArgs e)
|
private void OnProcessExited(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
ILauncherChildProcess? process;
|
int? exitCode;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
process = _process;
|
exitCode = _process is { HasExited: true } process
|
||||||
|
? process.ExitCode
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
ExitCode = process is { HasExited: true } ? process.ExitCode : null;
|
SetState(LauncherSessionState.Exited, exitCode);
|
||||||
SetState(LauncherSessionState.Exited);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -171,20 +196,80 @@ public sealed class LauncherProcessSupervisor : IDisposable
|
||||||
/// — without this guard, "Running" would silently resurrect a
|
/// — without this guard, "Running" would silently resurrect a
|
||||||
/// process that has already reported its exit.
|
/// process that has already reported its exit.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void SetState(LauncherSessionState state)
|
private void SetState(LauncherSessionState state, int? exitCode = null)
|
||||||
{
|
{
|
||||||
|
bool publish;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (State == LauncherSessionState.Exited)
|
if (_state == LauncherSessionState.Exited)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
State = state;
|
_state = state;
|
||||||
|
if (state == LauncherSessionState.Exited)
|
||||||
|
{
|
||||||
|
_exitCode = exitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_pendingStateChanges.Enqueue(state);
|
||||||
|
publish = !_publishingStateChanges;
|
||||||
|
if (publish)
|
||||||
|
{
|
||||||
|
_publishingStateChanges = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publish)
|
||||||
|
{
|
||||||
|
PublishPendingStateChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drains state notifications through one publisher. Transition storage
|
||||||
|
/// stays under <see cref="_gate"/>, but user callbacks run outside it so
|
||||||
|
/// they may re-enter the supervisor or wait for another thread reading
|
||||||
|
/// state without deadlocking. A concurrent/re-entrant transition queues
|
||||||
|
/// behind the notification already in flight, preserving storage order in
|
||||||
|
/// the externally observed event stream.
|
||||||
|
/// </summary>
|
||||||
|
private void PublishPendingStateChanges()
|
||||||
|
{
|
||||||
|
Exception? firstException = null;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
LauncherSessionState state;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_pendingStateChanges.Count == 0)
|
||||||
|
{
|
||||||
|
_publishingStateChanges = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = _pendingStateChanges.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
StateChanged?.Invoke(this, state);
|
StateChanged?.Invoke(this, state);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Preserve the previous propagation behavior, but finish
|
||||||
|
// publishing any transition already committed concurrently
|
||||||
|
// (especially terminal Exited) before rethrowing the first
|
||||||
|
// observer failure to the initiating caller.
|
||||||
|
firstException ??= ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstException is not null)
|
||||||
|
{
|
||||||
|
ExceptionDispatchInfo.Capture(firstException).Throw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ namespace AcDream.Launcher.Core.Profiles;
|
||||||
public sealed class LauncherProfileStore
|
public sealed class LauncherProfileStore
|
||||||
{
|
{
|
||||||
internal const int CurrentVersion = 1;
|
internal const int CurrentVersion = 1;
|
||||||
|
internal const UnixFileMode OwnerOnlyFileMode =
|
||||||
|
UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
{
|
{
|
||||||
|
|
@ -115,14 +117,12 @@ public sealed class LauncherProfileStore
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists <see cref="Document"/> to <see cref="FilePath"/> via a
|
/// Persists <see cref="Document"/> to <see cref="FilePath"/> via a
|
||||||
/// write-then-atomic-rename so a crash mid-write never leaves a
|
/// write-then-atomic-rename so a crash mid-write never leaves a
|
||||||
/// truncated credentials file. On Linux, the temp file is chmod'd to
|
/// truncated credentials file. On Linux, the temp file is created
|
||||||
/// owner read/write (0600) immediately after creation — BEFORE any
|
/// atomically with owner read/write (0600) as its requested creation
|
||||||
/// plaintext credential is serialized into it — so there is no window
|
/// mode — before its path is observable and before any plaintext
|
||||||
/// where the temp file carries the process umask's (potentially
|
/// credential is serialized into it. The final path retains that mode
|
||||||
/// world/group-readable) default permissions while holding a
|
/// through the rename (Campaign LA's plaintext-credential decision,
|
||||||
/// password; the final path gets the same restriction after the
|
/// spec §5, decisions log).
|
||||||
/// rename (Campaign LA's plaintext-credential decision, spec §5,
|
|
||||||
/// decisions log; the temp-file window itself is review finding F4).
|
|
||||||
/// A failure between temp-file creation and the rename deletes the
|
/// A failure between temp-file creation and the rename deletes the
|
||||||
/// stale temp file rather than leaving it behind.
|
/// stale temp file rather than leaving it behind.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -135,15 +135,18 @@ public sealed class LauncherProfileStore
|
||||||
}
|
}
|
||||||
|
|
||||||
string tempPath = FilePath + ".tmp";
|
string tempPath = FilePath + ".tmp";
|
||||||
|
DeleteStaleTempFile(tempPath);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using (FileStream stream = File.Create(tempPath))
|
using (FileStream stream = CreateCredentialTempFile(tempPath))
|
||||||
{
|
{
|
||||||
if (OperatingSystem.IsLinux())
|
if (OperatingSystem.IsLinux())
|
||||||
{
|
{
|
||||||
File.SetUnixFileMode(
|
// UnixCreateMode is subject to the process umask. It
|
||||||
tempPath,
|
// guarantees the file is never created with group/other
|
||||||
UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
// access; normalize the owner bits while the still-empty
|
||||||
|
// file is open so the persisted contract is exactly 0600.
|
||||||
|
File.SetUnixFileMode(tempPath, OwnerOnlyFileMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonSerializer.Serialize(stream, Document, SerializerOptions);
|
JsonSerializer.Serialize(stream, Document, SerializerOptions);
|
||||||
|
|
@ -159,12 +162,39 @@ public sealed class LauncherProfileStore
|
||||||
|
|
||||||
if (OperatingSystem.IsLinux())
|
if (OperatingSystem.IsLinux())
|
||||||
{
|
{
|
||||||
File.SetUnixFileMode(
|
File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
|
||||||
FilePath,
|
|
||||||
UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the exact options used for the plaintext-credential temp
|
||||||
|
/// file. <see cref="FileMode.CreateNew"/> makes creation atomic and
|
||||||
|
/// refuses to follow an existing stale or raced path. On Linux,
|
||||||
|
/// <see cref="FileStreamOptions.UnixCreateMode"/> supplies 0600 to
|
||||||
|
/// the OS create operation itself, eliminating the observable
|
||||||
|
/// create-then-chmod window. Windows leaves UnixCreateMode unset and
|
||||||
|
/// therefore retains its normal user-profile ACL behavior.
|
||||||
|
/// </summary>
|
||||||
|
internal static FileStreamOptions CreateCredentialTempFileOptions()
|
||||||
|
{
|
||||||
|
var options = new FileStreamOptions
|
||||||
|
{
|
||||||
|
Mode = FileMode.CreateNew,
|
||||||
|
Access = FileAccess.Write,
|
||||||
|
Share = FileShare.None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
options.UnixCreateMode = OwnerOnlyFileMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static FileStream CreateCredentialTempFile(string tempPath) =>
|
||||||
|
new(tempPath, CreateCredentialTempFileOptions());
|
||||||
|
|
||||||
private static void DeleteStaleTempFile(string tempPath)
|
private static void DeleteStaleTempFile(string tempPath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,9 @@ public sealed record ExitedStatusEvent : StatusEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A well-formed status line whose <c>e</c> value (or overall envelope
|
/// A JSON-object status line whose non-empty string <c>e</c> value this
|
||||||
/// shape) this reader does not recognize. The tailer never throws on an
|
/// reader does not recognize. The tailer never throws on an unrecognized
|
||||||
/// unrecognized event — an older launcher reading a newer host's stream
|
/// event — an older launcher reading a newer host's stream
|
||||||
/// degrades to seeing <see cref="UnknownStatusEvent"/> rows instead of
|
/// degrades to seeing <see cref="UnknownStatusEvent"/> rows instead of
|
||||||
/// crashing.
|
/// crashing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -81,10 +81,10 @@ public sealed record UnknownStatusEvent : StatusEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A status line whose <c>e</c> value IS one of the recognized event
|
/// A complete JSON value that is not an object, an object without a
|
||||||
/// names, but whose payload does not match that event's expected shape
|
/// usable event name, or a known event whose pinned v1 envelope/payload
|
||||||
/// (a missing required field, or a field present with the wrong JSON
|
/// does not match its expected shape. Distinguished from
|
||||||
/// kind). Distinguished from <see cref="UnknownStatusEvent"/> (Campaign
|
/// <see cref="UnknownStatusEvent"/> (Campaign
|
||||||
/// LA plan §LA3 review finding F12) so a launcher can tell "a newer/older
|
/// LA plan §LA3 review finding F12) so a launcher can tell "a newer/older
|
||||||
/// host sent an event I've never heard of" apart from "a host I recognize
|
/// host sent an event I've never heard of" apart from "a host I recognize
|
||||||
/// sent me garbage for an event I do know" — the two cases call for
|
/// sent me garbage for an event I do know" — the two cases call for
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@ namespace AcDream.Launcher.Core.Status;
|
||||||
/// "accountName":"...","slotCount":6,"characters":[...]}</c>.
|
/// "accountName":"...","slotCount":6,"characters":[...]}</c>.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Never throws: a null/blank/malformed-JSON line, an unrecognized
|
/// Never throws: a null/blank/malformed-JSON line, a complete JSON value
|
||||||
/// <c>e</c> value, or a recognized <c>e</c> whose payload doesn't match
|
/// with a non-object root, an unrecognized <c>e</c> value, or a recognized
|
||||||
/// that event's expected shape, all degrade to a typed event
|
/// <c>e</c> whose envelope/payload does not match the pinned v1 shape all
|
||||||
/// (<see cref="UnknownStatusEvent"/> or <see cref="MalformedStatusEvent"/>
|
/// degrade to a typed event (<see cref="UnknownStatusEvent"/> or
|
||||||
/// — see each type's docs) rather than an exception — a launcher must
|
/// <see cref="MalformedStatusEvent"/>) rather than an exception. A launcher
|
||||||
/// keep tailing a session's status stream even against a host running a
|
/// must keep tailing a session's status stream even against a host running a
|
||||||
/// newer/older wire version, or a host that briefly writes a torn line.
|
/// newer/older wire version or a host that writes a bad line.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class StatusEventParser
|
public static class StatusEventParser
|
||||||
|
|
@ -26,10 +26,6 @@ public static class StatusEventParser
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(line))
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
{
|
{
|
||||||
// Campaign LA plan §LA3 review finding F7: a blank/whitespace
|
|
||||||
// line is a normal "nothing complete here yet" degrade, not a
|
|
||||||
// caller error — the old ArgumentException.ThrowIfNullOrWhiteSpace
|
|
||||||
// guard ran BEFORE the try/catch below and escaped uncaught.
|
|
||||||
return UnknownEvent(line ?? string.Empty);
|
return UnknownEvent(line ?? string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,14 +42,51 @@ public static class StatusEventParser
|
||||||
using (document)
|
using (document)
|
||||||
{
|
{
|
||||||
JsonElement root = document.RootElement;
|
JsonElement root = document.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return MalformedEvent(
|
||||||
|
v: 0,
|
||||||
|
e: string.Empty,
|
||||||
|
t: default,
|
||||||
|
sessionId: string.Empty,
|
||||||
|
"status event root is not a JSON object.");
|
||||||
|
}
|
||||||
|
|
||||||
int v = GetInt32OrDefault(root, "v");
|
if (!TryGetEventName(root, out string e, out string eventNameError))
|
||||||
string e = GetStringOrDefault(root, "e");
|
{
|
||||||
DateTimeOffset t = GetDateTimeOffsetOrDefault(root, "t");
|
return MalformedEvent(
|
||||||
string sessionId = GetStringOrDefault(root, "sessionId");
|
GetInt32OrDefault(root, "v"),
|
||||||
|
e,
|
||||||
|
GetDateTimeOffsetOrDefault(root, "t"),
|
||||||
|
GetStringOrDefault(root, "sessionId"),
|
||||||
|
eventNameError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genuinely unknown event name is the forward-compatibility
|
||||||
|
// case. Retain its best-effort envelope and raw JSON without
|
||||||
|
// imposing this launcher's known-event envelope/payload schema.
|
||||||
|
if (!IsKnownEventName(e))
|
||||||
|
{
|
||||||
|
return new UnknownStatusEvent
|
||||||
|
{
|
||||||
|
V = GetInt32OrDefault(root, "v"),
|
||||||
|
E = e,
|
||||||
|
T = GetDateTimeOffsetOrDefault(root, "t"),
|
||||||
|
SessionId = GetStringOrDefault(root, "sessionId"),
|
||||||
|
RawJson = line,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// The pinned v1 envelope applies to every known event,
|
||||||
|
// including payload-free started/connected rows. Defaulting
|
||||||
|
// malformed fields would turn corrupt or cross-version input
|
||||||
|
// into an apparently valid typed event.
|
||||||
|
int v = RequireVersionOne(root);
|
||||||
|
DateTimeOffset t = RequireUtcTimestamp(root, "t");
|
||||||
|
string sessionId = RequireNonEmptyString(root, "sessionId");
|
||||||
|
|
||||||
return e switch
|
return e switch
|
||||||
{
|
{
|
||||||
"started" =>
|
"started" =>
|
||||||
|
|
@ -72,38 +105,60 @@ public static class StatusEventParser
|
||||||
ParseDisconnected(root, v, e, t, sessionId),
|
ParseDisconnected(root, v, e, t, sessionId),
|
||||||
"exited" =>
|
"exited" =>
|
||||||
ParseExited(root, v, e, t, sessionId),
|
ParseExited(root, v, e, t, sessionId),
|
||||||
_ =>
|
_ => throw new InvalidOperationException("known event dispatch is incomplete."),
|
||||||
new UnknownStatusEvent
|
|
||||||
{
|
|
||||||
V = v,
|
|
||||||
E = e,
|
|
||||||
T = t,
|
|
||||||
SessionId = sessionId,
|
|
||||||
RawJson = line,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is FormatException or InvalidOperationException)
|
catch (Exception ex) when (ex is FormatException or InvalidOperationException)
|
||||||
{
|
{
|
||||||
// FormatException: a Require* helper found a missing
|
return MalformedEvent(
|
||||||
// field or a field of the wrong JSON kind (e.g.
|
GetInt32OrDefault(root, "v"),
|
||||||
// "secondsGreyedOut": true"). InvalidOperationException:
|
e,
|
||||||
// a JsonElement API call (EnumerateArray, TryGetProperty)
|
GetDateTimeOffsetOrDefault(root, "t"),
|
||||||
// against an element of the wrong ValueKind (e.g.
|
GetStringOrDefault(root, "sessionId"),
|
||||||
// "characters" present but not an array). Both mean `e`
|
ex.Message);
|
||||||
// WAS recognized but its payload wasn't — distinguished
|
}
|
||||||
// from UnknownStatusEvent (Campaign LA plan §LA3 review
|
}
|
||||||
// finding F12).
|
}
|
||||||
return new MalformedStatusEvent
|
|
||||||
|
private static bool IsKnownEventName(string eventName) =>
|
||||||
|
eventName is
|
||||||
|
"started" or
|
||||||
|
"connected" or
|
||||||
|
"characterList" or
|
||||||
|
"enteredWorld" or
|
||||||
|
"pluginLoaded" or
|
||||||
|
"pluginFailed" or
|
||||||
|
"disconnected" or
|
||||||
|
"exited";
|
||||||
|
|
||||||
|
private static bool TryGetEventName(
|
||||||
|
JsonElement root,
|
||||||
|
out string eventName,
|
||||||
|
out string error)
|
||||||
{
|
{
|
||||||
V = v,
|
if (!root.TryGetProperty("e", out JsonElement element))
|
||||||
E = e,
|
{
|
||||||
T = t,
|
eventName = string.Empty;
|
||||||
SessionId = sessionId,
|
error = "status event is missing 'e'.";
|
||||||
Error = ex.Message,
|
return false;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (element.ValueKind != JsonValueKind.String)
|
||||||
|
{
|
||||||
|
eventName = string.Empty;
|
||||||
|
error = "status event field 'e' is not a string.";
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eventName = element.GetString() ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(eventName))
|
||||||
|
{
|
||||||
|
error = "status event field 'e' is empty.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UnknownStatusEvent UnknownEvent(string rawLine) =>
|
private static UnknownStatusEvent UnknownEvent(string rawLine) =>
|
||||||
|
|
@ -116,6 +171,21 @@ public static class StatusEventParser
|
||||||
RawJson = rawLine,
|
RawJson = rawLine,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static MalformedStatusEvent MalformedEvent(
|
||||||
|
int v,
|
||||||
|
string e,
|
||||||
|
DateTimeOffset t,
|
||||||
|
string sessionId,
|
||||||
|
string error) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
V = v,
|
||||||
|
E = e,
|
||||||
|
T = t,
|
||||||
|
SessionId = sessionId,
|
||||||
|
Error = error,
|
||||||
|
};
|
||||||
|
|
||||||
private static StatusEvent ParseCharacterList(
|
private static StatusEvent ParseCharacterList(
|
||||||
JsonElement root,
|
JsonElement root,
|
||||||
int v,
|
int v,
|
||||||
|
|
@ -244,11 +314,8 @@ public static class StatusEventParser
|
||||||
string name) =>
|
string name) =>
|
||||||
root.TryGetProperty(name, out JsonElement element)
|
root.TryGetProperty(name, out JsonElement element)
|
||||||
&& element.ValueKind == JsonValueKind.String
|
&& element.ValueKind == JsonValueKind.String
|
||||||
&& DateTimeOffset.TryParse(
|
&& element.TryGetDateTimeOffset(out DateTimeOffset value)
|
||||||
element.GetString(),
|
&& value.Offset == TimeSpan.Zero
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
|
||||||
System.Globalization.DateTimeStyles.None,
|
|
||||||
out DateTimeOffset value)
|
|
||||||
? value
|
? value
|
||||||
: default;
|
: default;
|
||||||
|
|
||||||
|
|
@ -273,11 +340,52 @@ public static class StatusEventParser
|
||||||
: throw new FormatException($"status event field '{name}' is not an integer.");
|
: throw new FormatException($"status event field '{name}' is not an integer.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int RequireVersionOne(JsonElement root)
|
||||||
|
{
|
||||||
|
int version = RequireInt32(root, "v");
|
||||||
|
return version == 1
|
||||||
|
? version
|
||||||
|
: throw new FormatException(
|
||||||
|
$"status event version is {version}; expected 1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RequireNonEmptyString(JsonElement root, string name)
|
||||||
|
{
|
||||||
|
string value = RequireString(root, name);
|
||||||
|
return !string.IsNullOrWhiteSpace(value)
|
||||||
|
? value
|
||||||
|
: throw new FormatException($"status event field '{name}' is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset RequireUtcTimestamp(JsonElement root, string name)
|
||||||
|
{
|
||||||
|
JsonElement element = RequireProperty(root, name);
|
||||||
|
if (element.ValueKind != JsonValueKind.String)
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"status event field '{name}' is not an ISO-8601 UTC string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string text = element.GetString() ?? string.Empty;
|
||||||
|
bool hasExplicitUtcOffset = text.EndsWith('Z')
|
||||||
|
|| text.EndsWith("+00:00", StringComparison.Ordinal);
|
||||||
|
if (!hasExplicitUtcOffset
|
||||||
|
|| !element.TryGetDateTimeOffset(out DateTimeOffset value)
|
||||||
|
|| value.Offset != TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"status event field '{name}' is not an ISO-8601 UTC timestamp.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
private static uint RequireUInt32(JsonElement root, string name)
|
private static uint RequireUInt32(JsonElement root, string name)
|
||||||
{
|
{
|
||||||
JsonElement element = RequireProperty(root, name);
|
JsonElement element = RequireProperty(root, name);
|
||||||
return element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out uint value)
|
return element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out uint value)
|
||||||
? value
|
? value
|
||||||
: throw new FormatException($"status event field '{name}' is not an unsigned integer.");
|
: throw new FormatException(
|
||||||
|
$"status event field '{name}' is not an unsigned integer.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using AcDream.Launcher.Core.Launching;
|
using AcDream.Launcher.Core.Launching;
|
||||||
|
|
||||||
|
|
@ -235,6 +236,95 @@ public sealed class LauncherProcessSupervisorTests
|
||||||
states);
|
states);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentRunningAndExitedPublicationsRemainMonotonicAndInOrder()
|
||||||
|
{
|
||||||
|
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
|
||||||
|
using var supervisor = new LauncherProcessSupervisor(factory);
|
||||||
|
using var runningPublicationEntered = new ManualResetEventSlim(false);
|
||||||
|
using var releaseRunningPublication = new ManualResetEventSlim(false);
|
||||||
|
var states = new ConcurrentQueue<LauncherSessionState>();
|
||||||
|
|
||||||
|
supervisor.StateChanged += (_, state) =>
|
||||||
|
{
|
||||||
|
if (state == LauncherSessionState.Running)
|
||||||
|
{
|
||||||
|
runningPublicationEntered.Set();
|
||||||
|
Assert.True(
|
||||||
|
releaseRunningPublication.Wait(TimeSpan.FromSeconds(5)),
|
||||||
|
"test did not release the Running publication barrier");
|
||||||
|
}
|
||||||
|
|
||||||
|
states.Enqueue(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
Task startTask = Task.Run(() => supervisor.Start(Spec(), "pw"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(
|
||||||
|
runningPublicationEntered.Wait(TimeSpan.FromSeconds(5)),
|
||||||
|
"Running publication did not reach the test barrier");
|
||||||
|
|
||||||
|
// Commit Exited while Running's observer is deliberately
|
||||||
|
// paused. Storage reaches the terminal state immediately, but
|
||||||
|
// publication must queue behind the earlier Running event.
|
||||||
|
factory.LastCreated!.ExitForTest(17);
|
||||||
|
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
releaseRunningPublication.Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
await startTask.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
LauncherSessionState.Starting,
|
||||||
|
LauncherSessionState.Running,
|
||||||
|
LauncherSessionState.Exited,
|
||||||
|
],
|
||||||
|
states);
|
||||||
|
Assert.Equal(17, supervisor.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StateChangedPublicationAllowsCrossThreadReadsAndReentrantExit()
|
||||||
|
{
|
||||||
|
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
|
||||||
|
using var supervisor = new LauncherProcessSupervisor(factory);
|
||||||
|
var states = new List<LauncherSessionState>();
|
||||||
|
|
||||||
|
supervisor.StateChanged += (_, state) =>
|
||||||
|
{
|
||||||
|
states.Add(state);
|
||||||
|
if (state != LauncherSessionState.Running)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A publisher that invokes callbacks while holding the state
|
||||||
|
// gate deadlocks this cross-thread read. The callback also
|
||||||
|
// raises Exited re-entrantly; it must queue after Running rather
|
||||||
|
// than recurse out of order or deadlock.
|
||||||
|
Task<LauncherSessionState> readTask = Task.Run(() => supervisor.State);
|
||||||
|
Assert.True(readTask.Wait(TimeSpan.FromSeconds(5)));
|
||||||
|
Assert.Equal(LauncherSessionState.Running, readTask.Result);
|
||||||
|
factory.LastCreated!.ExitForTest(23);
|
||||||
|
};
|
||||||
|
|
||||||
|
supervisor.Start(Spec(), "pw");
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
LauncherSessionState.Starting,
|
||||||
|
LauncherSessionState.Running,
|
||||||
|
LauncherSessionState.Exited,
|
||||||
|
],
|
||||||
|
states);
|
||||||
|
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
|
||||||
|
Assert.Equal(23, supervisor.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LauncherProcessSpecCarriesNoCredentialLikeMember()
|
public void LauncherProcessSpecCarriesNoCredentialLikeMember()
|
||||||
{
|
{
|
||||||
|
|
@ -358,12 +448,22 @@ public sealed class LauncherProcessSupervisorTests
|
||||||
{
|
{
|
||||||
// Simulates a child that dies synchronously from inside
|
// Simulates a child that dies synchronously from inside
|
||||||
// Process.Start() itself (review finding F9's race).
|
// Process.Start() itself (review finding F9's race).
|
||||||
HasExited = true;
|
ExitForTest(0);
|
||||||
ExitCode = 0;
|
|
||||||
Exited?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ExitForTest(int exitCode)
|
||||||
|
{
|
||||||
|
if (HasExited)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HasExited = true;
|
||||||
|
ExitCode = exitCode;
|
||||||
|
Exited?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
public bool TryRequestGracefulStop()
|
public bool TryRequestGracefulStop()
|
||||||
{
|
{
|
||||||
TryRequestGracefulStopCallCount++;
|
TryRequestGracefulStopCallCount++;
|
||||||
|
|
@ -382,9 +482,7 @@ public sealed class LauncherProcessSupervisorTests
|
||||||
{
|
{
|
||||||
KillCallCount++;
|
KillCallCount++;
|
||||||
CallOrder.Add("kill");
|
CallOrder.Add("kill");
|
||||||
HasExited = true;
|
ExitForTest(-1);
|
||||||
ExitCode = -1;
|
|
||||||
Exited?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool WaitForExit(TimeSpan timeout)
|
public bool WaitForExit(TimeSpan timeout)
|
||||||
|
|
@ -392,9 +490,7 @@ public sealed class LauncherProcessSupervisorTests
|
||||||
if (!exitsWithinStopTimeout)
|
if (!exitsWithinStopTimeout)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
HasExited = true;
|
ExitForTest(0);
|
||||||
ExitCode = 0;
|
|
||||||
Exited?.Invoke(this, EventArgs.Empty);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using System.Threading;
|
|
||||||
using AcDream.Launcher.Core.Profiles;
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
|
||||||
namespace AcDream.Launcher.Core.Tests.Profiles;
|
namespace AcDream.Launcher.Core.Tests.Profiles;
|
||||||
|
|
@ -287,68 +286,42 @@ public sealed class LauncherProfileStoreTests : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SaveNeverLeavesTheTempFileWorldOrGroupReadableDuringTheWrite()
|
public void TempCredentialCreationOptionsRequestAtomicPlatformCorrectCreation()
|
||||||
{
|
{
|
||||||
// Review finding F4: the temp file used to be created with the
|
FileStreamOptions options =
|
||||||
// process's default umask and only chmod'd AFTER the atomic
|
LauncherProfileStore.CreateCredentialTempFileOptions();
|
||||||
// rename, leaving a window where the plaintext-credential temp
|
Assert.Equal(FileMode.CreateNew, options.Mode);
|
||||||
// file could be world/group-readable. The fix chmods the temp
|
Assert.Equal(FileAccess.Write, options.Access);
|
||||||
// file immediately after creation, BEFORE any content (including
|
Assert.Equal(FileShare.None, options.Share);
|
||||||
// the password) is serialized into it. A large document makes
|
|
||||||
// the write take long enough for a concurrent poller to have a
|
if (OperatingSystem.IsLinux())
|
||||||
// real chance at observing a regression.
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
LauncherProfileStore.OwnerOnlyFileMode,
|
||||||
|
options.UnixCreateMode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.Null(options.UnixCreateMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TempCredentialFileIsOwnerOnlyFromItsFirstObservableLinuxState()
|
||||||
|
{
|
||||||
|
// Deterministic proof of the exact production create path: inspect
|
||||||
|
// the file while the CreateNew handle is still open, before any
|
||||||
|
// serialization or post-create chmod can occur. This replaces the
|
||||||
|
// old timing-only poller, which could miss the vulnerable window.
|
||||||
if (!OperatingSystem.IsLinux())
|
if (!OperatingSystem.IsLinux())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var store = new LauncherProfileStore(_filePath);
|
|
||||||
store.Load();
|
|
||||||
store.AddServer("Local ACE", "127.0.0.1", 9000);
|
|
||||||
for (int i = 0; i < 300; i++)
|
|
||||||
{
|
|
||||||
store.AddAccount("Local ACE", $"account{i}", new string('x', 4096));
|
|
||||||
}
|
|
||||||
|
|
||||||
string tempPath = _filePath + ".tmp";
|
string tempPath = _filePath + ".tmp";
|
||||||
bool observedLooseMode = false;
|
using FileStream stream = LauncherProfileStore.CreateCredentialTempFile(tempPath);
|
||||||
bool stop = false;
|
|
||||||
var poller = new Thread(() =>
|
|
||||||
{
|
|
||||||
while (!Volatile.Read(ref stop))
|
|
||||||
{
|
|
||||||
if (File.Exists(tempPath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// The platform-compat analyzer can't see the
|
|
||||||
// enclosing test method's `OperatingSystem.IsLinux()`
|
|
||||||
// guard across this lambda boundary; suppressed
|
|
||||||
// rather than restructured, since the guard is
|
|
||||||
// real and this whole method is a no-op off Linux.
|
|
||||||
#pragma warning disable CA1416
|
|
||||||
UnixFileMode mode = File.GetUnixFileMode(tempPath);
|
|
||||||
#pragma warning restore CA1416
|
|
||||||
if ((mode & ~(UnixFileMode.UserRead | UnixFileMode.UserWrite)) != 0)
|
|
||||||
{
|
|
||||||
observedLooseMode = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (IOException)
|
|
||||||
{
|
|
||||||
// Renamed/deleted between the Exists check and
|
|
||||||
// GetUnixFileMode — not a finding, just keep
|
|
||||||
// polling.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
poller.Start();
|
|
||||||
|
|
||||||
store.Save();
|
Assert.Equal(
|
||||||
|
LauncherProfileStore.OwnerOnlyFileMode,
|
||||||
Volatile.Write(ref stop, true);
|
File.GetUnixFileMode(tempPath));
|
||||||
poller.Join();
|
|
||||||
|
|
||||||
Assert.False(observedLooseMode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,19 @@ public sealed class StatusEventParserTests
|
||||||
Assert.IsType<UnknownStatusEvent>(e);
|
Assert.IsType<UnknownStatusEvent>(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("[]")]
|
||||||
|
[InlineData("null")]
|
||||||
|
[InlineData("42")]
|
||||||
|
[InlineData("\"text\"")]
|
||||||
|
public void CompleteJsonWithANonObjectRootSurfacesAsMalformedEvent(string line)
|
||||||
|
{
|
||||||
|
var e = StatusEventParser.Parse(line);
|
||||||
|
|
||||||
|
var malformed = Assert.IsType<MalformedStatusEvent>(e);
|
||||||
|
Assert.Contains("root", malformed.Error, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("")]
|
[InlineData("")]
|
||||||
[InlineData(" ")]
|
[InlineData(" ")]
|
||||||
|
|
@ -136,6 +149,33 @@ public sealed class StatusEventParserTests
|
||||||
Assert.IsType<UnknownStatusEvent>(e);
|
Assert.IsType<UnknownStatusEvent>(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("{\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
|
||||||
|
[InlineData("{\"v\":\"1\",\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
|
||||||
|
[InlineData("{\"v\":2,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
|
||||||
|
[InlineData("{\"v\":1,\"e\":\"connected\",\"sessionId\":\"s1\"}")]
|
||||||
|
[InlineData("{\"v\":1,\"e\":\"started\",\"t\":42,\"sessionId\":\"s1\"}")]
|
||||||
|
[InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"not-a-time\",\"sessionId\":\"s1\"}")]
|
||||||
|
[InlineData("{\"v\":1,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00+02:00\",\"sessionId\":\"s1\"}")]
|
||||||
|
[InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\"}")]
|
||||||
|
[InlineData("{\"v\":1,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":42}")]
|
||||||
|
[InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"\"}")]
|
||||||
|
public void PayloadFreeKnownEventsRequireTheFullPinnedV1Envelope(string line)
|
||||||
|
{
|
||||||
|
var e = StatusEventParser.Parse(line);
|
||||||
|
|
||||||
|
var malformed = Assert.IsType<MalformedStatusEvent>(e);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("{\"v\":1,\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
|
||||||
|
[InlineData("{\"v\":1,\"e\":42,\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
|
||||||
|
public void MissingOrWrongKindEventNameSurfacesAsMalformedEvent(string line)
|
||||||
|
{
|
||||||
|
Assert.IsType<MalformedStatusEvent>(StatusEventParser.Parse(line));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void KnownEValueWithMissingRequiredFieldSurfacesAsMalformedEventRatherThanThrowing()
|
public void KnownEValueWithMissingRequiredFieldSurfacesAsMalformedEventRatherThanThrowing()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,21 @@ public sealed class StatusFileTailerTests : IDisposable
|
||||||
Assert.IsType<ConnectedStatusEvent>(events[1]);
|
Assert.IsType<ConnectedStatusEvent>(events[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ContinuesPastCompleteNonObjectJsonValuesToTheFollowingValidLine()
|
||||||
|
{
|
||||||
|
AppendShared(
|
||||||
|
"[]\nnull\n42\n\"text\"\n"
|
||||||
|
+ Line("connected", "s1"));
|
||||||
|
var tailer = new StatusFileTailer(_path);
|
||||||
|
|
||||||
|
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
|
||||||
|
|
||||||
|
Assert.Equal(5, events.Count);
|
||||||
|
Assert.All(events.Take(4), e => Assert.IsType<MalformedStatusEvent>(e));
|
||||||
|
Assert.IsType<ConnectedStatusEvent>(events[4]);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TolerateAPartialLastLineAndCompletesItOnALaterPoll()
|
public void TolerateAPartialLastLineAndCompletesItOnALaterPoll()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue