diff --git a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj
index f31af6e8..85dc49f0 100644
--- a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj
+++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj
@@ -12,6 +12,9 @@
context. -->
true
+
+
+
diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs
index f9b10694..0f7fcac1 100644
--- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs
+++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs
@@ -1,3 +1,5 @@
+using System.Runtime.ExceptionServices;
+
namespace AcDream.Launcher.Core.Launching;
///
@@ -10,19 +12,41 @@ public sealed class LauncherProcessSupervisor : IDisposable
{
private readonly ILauncherChildProcessFactory _factory;
private readonly object _gate = new();
+ private readonly Queue _pendingStateChanges = [];
private ILauncherChildProcess? _process;
+ private LauncherSessionState _state = LauncherSessionState.Starting;
+ private int? _exitCode;
+ private bool _publishingStateChanges;
public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
{
_factory = factory ?? new SystemChildProcessFactory();
}
- public LauncherSessionState State { get; private set; } = LauncherSessionState.Starting;
+ public LauncherSessionState State
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _state;
+ }
+ }
+ }
/// Set once reaches
/// ; null before then.
///
- public int? ExitCode { get; private set; }
+ public int? ExitCode
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _exitCode;
+ }
+ }
+ }
/// Fires on every
/// transition, in order.
@@ -148,14 +172,15 @@ public sealed class LauncherProcessSupervisor : IDisposable
private void OnProcessExited(object? sender, EventArgs e)
{
- ILauncherChildProcess? process;
+ int? exitCode;
lock (_gate)
{
- process = _process;
+ exitCode = _process is { HasExited: true } process
+ ? process.ExitCode
+ : null;
}
- ExitCode = process is { HasExited: true } ? process.ExitCode : null;
- SetState(LauncherSessionState.Exited);
+ SetState(LauncherSessionState.Exited, exitCode);
}
///
@@ -171,19 +196,79 @@ public sealed class LauncherProcessSupervisor : IDisposable
/// — without this guard, "Running" would silently resurrect a
/// process that has already reported its exit.
///
- private void SetState(LauncherSessionState state)
+ private void SetState(LauncherSessionState state, int? exitCode = null)
{
+ bool publish;
lock (_gate)
{
- if (State == LauncherSessionState.Exited)
+ if (_state == LauncherSessionState.Exited)
{
return;
}
- State = state;
+ _state = state;
+ if (state == LauncherSessionState.Exited)
+ {
+ _exitCode = exitCode;
+ }
+
+ _pendingStateChanges.Enqueue(state);
+ publish = !_publishingStateChanges;
+ if (publish)
+ {
+ _publishingStateChanges = true;
+ }
}
- StateChanged?.Invoke(this, state);
+ if (publish)
+ {
+ PublishPendingStateChanges();
+ }
+ }
+
+ ///
+ /// Drains state notifications through one publisher. Transition storage
+ /// stays under , 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.
+ ///
+ 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);
+ }
+ 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()
diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs
index 26f3957e..25d30787 100644
--- a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs
+++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs
@@ -19,6 +19,8 @@ namespace AcDream.Launcher.Core.Profiles;
public sealed class LauncherProfileStore
{
internal const int CurrentVersion = 1;
+ internal const UnixFileMode OwnerOnlyFileMode =
+ UnixFileMode.UserRead | UnixFileMode.UserWrite;
private static readonly JsonSerializerOptions SerializerOptions = new()
{
@@ -115,14 +117,12 @@ public sealed class LauncherProfileStore
///
/// Persists to via 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
- /// owner read/write (0600) immediately after creation — BEFORE any
- /// plaintext credential is serialized into it — so there is no window
- /// where the temp file carries the process umask's (potentially
- /// world/group-readable) default permissions while holding a
- /// password; the final path gets the same restriction after the
- /// rename (Campaign LA's plaintext-credential decision, spec §5,
- /// decisions log; the temp-file window itself is review finding F4).
+ /// truncated credentials file. On Linux, the temp file is created
+ /// atomically with owner read/write (0600) as its requested creation
+ /// mode — before its path is observable and before any plaintext
+ /// credential is serialized into it. The final path retains that mode
+ /// through the rename (Campaign LA's plaintext-credential decision,
+ /// spec §5, decisions log).
/// A failure between temp-file creation and the rename deletes the
/// stale temp file rather than leaving it behind.
///
@@ -135,15 +135,18 @@ public sealed class LauncherProfileStore
}
string tempPath = FilePath + ".tmp";
+ DeleteStaleTempFile(tempPath);
try
{
- using (FileStream stream = File.Create(tempPath))
+ using (FileStream stream = CreateCredentialTempFile(tempPath))
{
if (OperatingSystem.IsLinux())
{
- File.SetUnixFileMode(
- tempPath,
- UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ // UnixCreateMode is subject to the process umask. It
+ // guarantees the file is never created with group/other
+ // 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);
@@ -159,12 +162,39 @@ public sealed class LauncherProfileStore
if (OperatingSystem.IsLinux())
{
- File.SetUnixFileMode(
- FilePath,
- UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
}
}
+ ///
+ /// Builds the exact options used for the plaintext-credential temp
+ /// file. makes creation atomic and
+ /// refuses to follow an existing stale or raced path. On Linux,
+ /// 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.
+ ///
+ 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)
{
try
diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs
index b4471e36..c090b6e3 100644
--- a/src/AcDream.Launcher.Core/Status/StatusEvent.cs
+++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs
@@ -69,9 +69,9 @@ public sealed record ExitedStatusEvent : StatusEvent
}
///
-/// A well-formed status line whose e value (or overall envelope
-/// shape) this reader does not recognize. The tailer never throws on an
-/// unrecognized event — an older launcher reading a newer host's stream
+/// A JSON-object status line whose non-empty string e value this
+/// reader does not recognize. The tailer never throws on an unrecognized
+/// event — an older launcher reading a newer host's stream
/// degrades to seeing rows instead of
/// crashing.
///
@@ -81,10 +81,10 @@ public sealed record UnknownStatusEvent : StatusEvent
}
///
-/// A status line whose e value IS one of the recognized event
-/// names, but whose payload does not match that event's expected shape
-/// (a missing required field, or a field present with the wrong JSON
-/// kind). Distinguished from (Campaign
+/// A complete JSON value that is not an object, an object without a
+/// usable event name, or a known event whose pinned v1 envelope/payload
+/// does not match its expected shape. Distinguished from
+/// (Campaign
/// 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
/// sent me garbage for an event I do know" — the two cases call for
diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs
index 112ddfe5..f6811a60 100644
--- a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs
+++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs
@@ -11,13 +11,13 @@ namespace AcDream.Launcher.Core.Status;
/// "accountName":"...","slotCount":6,"characters":[...]}.
///
///
-/// Never throws: a null/blank/malformed-JSON line, an unrecognized
-/// e value, or a recognized e whose payload doesn't match
-/// that event's expected shape, all degrade to a typed event
-/// ( or
-/// — see each type's docs) rather than an exception — a launcher 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.
+/// Never throws: a null/blank/malformed-JSON line, a complete JSON value
+/// with a non-object root, an unrecognized e value, or a recognized
+/// e whose envelope/payload does not match the pinned v1 shape all
+/// degrade to a typed event ( or
+/// ) rather than an exception. A launcher
+/// must keep tailing a session's status stream even against a host running a
+/// newer/older wire version or a host that writes a bad line.
///
///
public static class StatusEventParser
@@ -26,10 +26,6 @@ public static class StatusEventParser
{
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);
}
@@ -46,14 +42,51 @@ public static class StatusEventParser
using (document)
{
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");
- string e = GetStringOrDefault(root, "e");
- DateTimeOffset t = GetDateTimeOffsetOrDefault(root, "t");
- string sessionId = GetStringOrDefault(root, "sessionId");
+ if (!TryGetEventName(root, out string e, out string eventNameError))
+ {
+ return MalformedEvent(
+ 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
{
+ // 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
{
"started" =>
@@ -72,40 +105,62 @@ public static class StatusEventParser
ParseDisconnected(root, v, e, t, sessionId),
"exited" =>
ParseExited(root, v, e, t, sessionId),
- _ =>
- new UnknownStatusEvent
- {
- V = v,
- E = e,
- T = t,
- SessionId = sessionId,
- RawJson = line,
- },
+ _ => throw new InvalidOperationException("known event dispatch is incomplete."),
};
}
catch (Exception ex) when (ex is FormatException or InvalidOperationException)
{
- // FormatException: a Require* helper found a missing
- // field or a field of the wrong JSON kind (e.g.
- // "secondsGreyedOut": true"). InvalidOperationException:
- // a JsonElement API call (EnumerateArray, TryGetProperty)
- // against an element of the wrong ValueKind (e.g.
- // "characters" present but not an array). Both mean `e`
- // WAS recognized but its payload wasn't — distinguished
- // from UnknownStatusEvent (Campaign LA plan §LA3 review
- // finding F12).
- return new MalformedStatusEvent
- {
- V = v,
- E = e,
- T = t,
- SessionId = sessionId,
- Error = ex.Message,
- };
+ return MalformedEvent(
+ GetInt32OrDefault(root, "v"),
+ e,
+ GetDateTimeOffsetOrDefault(root, "t"),
+ GetStringOrDefault(root, "sessionId"),
+ ex.Message);
}
}
}
+ 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)
+ {
+ if (!root.TryGetProperty("e", out JsonElement element))
+ {
+ eventName = string.Empty;
+ error = "status event is missing 'e'.";
+ 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) =>
new()
{
@@ -116,6 +171,21 @@ public static class StatusEventParser
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(
JsonElement root,
int v,
@@ -244,11 +314,8 @@ public static class StatusEventParser
string name) =>
root.TryGetProperty(name, out JsonElement element)
&& element.ValueKind == JsonValueKind.String
- && DateTimeOffset.TryParse(
- element.GetString(),
- System.Globalization.CultureInfo.InvariantCulture,
- System.Globalization.DateTimeStyles.None,
- out DateTimeOffset value)
+ && element.TryGetDateTimeOffset(out DateTimeOffset value)
+ && value.Offset == TimeSpan.Zero
? value
: default;
@@ -273,11 +340,52 @@ public static class StatusEventParser
: 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)
{
JsonElement element = RequireProperty(root, name);
return element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out uint 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.");
}
}
diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
index bab8b6c2..4ec1431e 100644
--- a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using System.Threading;
using AcDream.Launcher.Core.Launching;
@@ -235,6 +236,95 @@ public sealed class LauncherProcessSupervisorTests
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();
+
+ 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();
+
+ 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 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]
public void LauncherProcessSpecCarriesNoCredentialLikeMember()
{
@@ -358,12 +448,22 @@ public sealed class LauncherProcessSupervisorTests
{
// Simulates a child that dies synchronously from inside
// Process.Start() itself (review finding F9's race).
- HasExited = true;
- ExitCode = 0;
- Exited?.Invoke(this, EventArgs.Empty);
+ ExitForTest(0);
}
}
+ public void ExitForTest(int exitCode)
+ {
+ if (HasExited)
+ {
+ return;
+ }
+
+ HasExited = true;
+ ExitCode = exitCode;
+ Exited?.Invoke(this, EventArgs.Empty);
+ }
+
public bool TryRequestGracefulStop()
{
TryRequestGracefulStopCallCount++;
@@ -382,9 +482,7 @@ public sealed class LauncherProcessSupervisorTests
{
KillCallCount++;
CallOrder.Add("kill");
- HasExited = true;
- ExitCode = -1;
- Exited?.Invoke(this, EventArgs.Empty);
+ ExitForTest(-1);
}
public bool WaitForExit(TimeSpan timeout)
@@ -392,9 +490,7 @@ public sealed class LauncherProcessSupervisorTests
if (!exitsWithinStopTimeout)
return false;
- HasExited = true;
- ExitCode = 0;
- Exited?.Invoke(this, EventArgs.Empty);
+ ExitForTest(0);
return true;
}
diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs
index 957fa2cb..8d83c88d 100644
--- a/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs
@@ -1,4 +1,3 @@
-using System.Threading;
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Tests.Profiles;
@@ -287,68 +286,42 @@ public sealed class LauncherProfileStoreTests : IDisposable
}
[Fact]
- public void SaveNeverLeavesTheTempFileWorldOrGroupReadableDuringTheWrite()
+ public void TempCredentialCreationOptionsRequestAtomicPlatformCorrectCreation()
{
- // Review finding F4: the temp file used to be created with the
- // process's default umask and only chmod'd AFTER the atomic
- // rename, leaving a window where the plaintext-credential temp
- // file could be world/group-readable. The fix chmods the temp
- // file immediately after creation, BEFORE any content (including
- // the password) is serialized into it. A large document makes
- // the write take long enough for a concurrent poller to have a
- // real chance at observing a regression.
+ FileStreamOptions options =
+ LauncherProfileStore.CreateCredentialTempFileOptions();
+ Assert.Equal(FileMode.CreateNew, options.Mode);
+ Assert.Equal(FileAccess.Write, options.Access);
+ Assert.Equal(FileShare.None, options.Share);
+
+ if (OperatingSystem.IsLinux())
+ {
+ 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())
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";
- bool observedLooseMode = false;
- 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();
+ using FileStream stream = LauncherProfileStore.CreateCredentialTempFile(tempPath);
- store.Save();
-
- Volatile.Write(ref stop, true);
- poller.Join();
-
- Assert.False(observedLooseMode);
+ Assert.Equal(
+ LauncherProfileStore.OwnerOnlyFileMode,
+ File.GetUnixFileMode(tempPath));
}
[Fact]
diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs
index d28b642b..47f5e118 100644
--- a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs
@@ -112,6 +112,19 @@ public sealed class StatusEventParserTests
Assert.IsType(e);
}
+ [Theory]
+ [InlineData("[]")]
+ [InlineData("null")]
+ [InlineData("42")]
+ [InlineData("\"text\"")]
+ public void CompleteJsonWithANonObjectRootSurfacesAsMalformedEvent(string line)
+ {
+ var e = StatusEventParser.Parse(line);
+
+ var malformed = Assert.IsType(e);
+ Assert.Contains("root", malformed.Error, StringComparison.OrdinalIgnoreCase);
+ }
+
[Theory]
[InlineData("")]
[InlineData(" ")]
@@ -136,6 +149,33 @@ public sealed class StatusEventParserTests
Assert.IsType(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(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(StatusEventParser.Parse(line));
+ }
+
[Fact]
public void KnownEValueWithMissingRequiredFieldSurfacesAsMalformedEventRatherThanThrowing()
{
diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs
index b619fe25..d2ad011c 100644
--- a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs
@@ -61,6 +61,21 @@ public sealed class StatusFileTailerTests : IDisposable
Assert.IsType(events[1]);
}
+ [Fact]
+ public void ContinuesPastCompleteNonObjectJsonValuesToTheFollowingValidLine()
+ {
+ AppendShared(
+ "[]\nnull\n42\n\"text\"\n"
+ + Line("connected", "s1"));
+ var tailer = new StatusFileTailer(_path);
+
+ IReadOnlyList events = tailer.ReadNewEvents();
+
+ Assert.Equal(5, events.Count);
+ Assert.All(events.Take(4), e => Assert.IsType(e));
+ Assert.IsType(events[4]);
+ }
+
[Fact]
public void TolerateAPartialLastLineAndCompletesItOnALaterPoll()
{