fix(launcher): Campaign LA LA3 narrow review fixes

This commit is contained in:
Erik 2026-08-14 17:06:47 +02:00
parent 26feba8186
commit 347a1a5d16
9 changed files with 495 additions and 145 deletions

View file

@ -12,6 +12,9 @@
context. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
</ItemGroup>

View file

@ -1,3 +1,5 @@
using System.Runtime.ExceptionServices;
namespace AcDream.Launcher.Core.Launching;
/// <summary>
@ -10,19 +12,41 @@ public sealed class LauncherProcessSupervisor : IDisposable
{
private readonly ILauncherChildProcessFactory _factory;
private readonly object _gate = new();
private readonly Queue<LauncherSessionState> _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;
}
}
}
/// <summary>Set once <see cref="State"/> reaches
/// <see cref="LauncherSessionState.Exited"/>; null before then.
/// </summary>
public int? ExitCode { get; private set; }
public int? ExitCode
{
get
{
lock (_gate)
{
return _exitCode;
}
}
}
/// <summary>Fires on every <see cref="LauncherSessionState"/>
/// transition, in order.</summary>
@ -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);
}
/// <summary>
@ -171,19 +196,79 @@ public sealed class LauncherProcessSupervisor : IDisposable
/// — without this guard, "Running" would silently resurrect a
/// process that has already reported its exit.
/// </summary>
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();
}
}
/// <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);
}
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()

View file

@ -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
/// <summary>
/// Persists <see cref="Document"/> to <see cref="FilePath"/> 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.
/// </summary>
@ -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);
}
}
/// <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)
{
try

View file

@ -69,9 +69,9 @@ public sealed record ExitedStatusEvent : StatusEvent
}
/// <summary>
/// A well-formed status line whose <c>e</c> 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 <c>e</c> 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 <see cref="UnknownStatusEvent"/> rows instead of
/// crashing.
/// </summary>
@ -81,10 +81,10 @@ public sealed record UnknownStatusEvent : StatusEvent
}
/// <summary>
/// A status line whose <c>e</c> 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 <see cref="UnknownStatusEvent"/> (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
/// <see cref="UnknownStatusEvent"/> (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

View file

@ -11,13 +11,13 @@ namespace AcDream.Launcher.Core.Status;
/// "accountName":"...","slotCount":6,"characters":[...]}</c>.
///
/// <para>
/// Never throws: a null/blank/malformed-JSON line, an unrecognized
/// <c>e</c> value, or a recognized <c>e</c> whose payload doesn't match
/// that event's expected shape, all degrade to a typed event
/// (<see cref="UnknownStatusEvent"/> or <see cref="MalformedStatusEvent"/>
/// — 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 <c>e</c> value, or a recognized
/// <c>e</c> whose envelope/payload does not match the pinned v1 shape all
/// degrade to a typed event (<see cref="UnknownStatusEvent"/> or
/// <see cref="MalformedStatusEvent"/>) 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.
/// </para>
/// </summary>
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.");
}
}

View file

@ -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<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]
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;
}

View file

@ -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]

View file

@ -112,6 +112,19 @@ public sealed class StatusEventParserTests
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]
[InlineData("")]
[InlineData(" ")]
@ -136,6 +149,33 @@ public sealed class StatusEventParserTests
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]
public void KnownEValueWithMissingRequiredFieldSurfacesAsMalformedEventRatherThanThrowing()
{

View file

@ -61,6 +61,21 @@ public sealed class StatusFileTailerTests : IDisposable
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]
public void TolerateAPartialLastLineAndCompletesItOnALaterPoll()
{