From 75a6724d5b2505c27929a5d7785e731c018b3d4d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:32:52 +0200 Subject: [PATCH] =?UTF-8?q?wip:=20Campaign=20LA=20LA1=20fix=20round=20?= =?UTF-8?q?=E2=80=94=20INCOMPLETE,=20stopped=20mid-task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent was stopped for token budget partway through the LA1 review fix round. Landed here: F1 best-effort SessionStatusWriter, F2 App reader tolerance (paths/mode), F5 argument-parsing hardening, plus new tests. NOT DONE: F4 shared-fixture production shape (was the next step), F3 reconnect disconnected edge + recorded limitation, F6 exited idempotency/reasons, F7 structural redaction test, F8 platform-guard test + comment fix, optional RuntimeOptions PrintMembers redaction. Build/test state UNVERIFIED at this commit. Next session: finish the remaining findings, run the suites, then narrow re-review. Co-Authored-By: Claude Fable 5 --- .../SessionConfigArgumentParsing.cs | 73 +++++++++ .../Configuration/SessionConfiguration.cs | 33 +++++ .../SessionConfigurationLoader.cs | 26 ++++ src/AcDream.App/Program.cs | 51 +++---- .../Session/SessionStatusWriter.cs | 127 ++++++++++++++-- .../SessionConfigArgumentParsingTests.cs | 97 ++++++++++++ .../SessionConfigurationLoaderTests.cs | 140 ++++++++++++++++++ .../Session/SessionStatusWriterTests.cs | 123 ++++++++++++++- .../session-config-shared-fixture.json | 10 +- 9 files changed, 628 insertions(+), 52 deletions(-) create mode 100644 src/AcDream.App/Configuration/SessionConfigArgumentParsing.cs create mode 100644 tests/AcDream.App.Tests/Configuration/SessionConfigArgumentParsingTests.cs create mode 100644 tests/AcDream.App.Tests/Configuration/SessionConfigurationLoaderTests.cs diff --git a/src/AcDream.App/Configuration/SessionConfigArgumentParsing.cs b/src/AcDream.App/Configuration/SessionConfigArgumentParsing.cs new file mode 100644 index 00000000..96d2f43c --- /dev/null +++ b/src/AcDream.App/Configuration/SessionConfigArgumentParsing.cs @@ -0,0 +1,73 @@ +namespace AcDream.App.Configuration; + +/// +/// Campaign LA slice LA1 review fix (F5): extracted from Program.cs's +/// top-level-statement local functions so the trailing-flag edge case is +/// unit testable — a top-level program's local functions are compiler- +/// synthesized private members of the generated Program class with +/// no stable surface a test assembly can reach. +/// +internal static class SessionConfigArgumentParsing +{ + /// + /// Finds in and + /// returns its value. Three distinct outcomes, distinguished by + /// and the return value together: + /// + /// flag absent: = , + /// returns — the caller's env-var/positional + /// fallback stays in effect, unchanged from before this flag + /// existed. + /// flag present with a following value: + /// = , returns that value. + /// flag present but is the LAST argument, with nothing after it: + /// = , returns + /// — the caller MUST treat this as a hard error + /// (the flag was typed but its value was not), never silently fall + /// through to the flag-absent path. + /// + /// + internal static string? ExtractFlagValue( + string[] arguments, + string flag, + out bool present) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentException.ThrowIfNullOrWhiteSpace(flag); + + for (int i = 0; i < arguments.Length; i++) + { + if (!string.Equals(arguments[i], flag, StringComparison.Ordinal)) + continue; + + present = true; + return i == arguments.Length - 1 ? null : arguments[i + 1]; + } + + present = false; + return null; + } + + /// Returns with + /// and its following value (if any) removed. A trailing, valueless flag + /// is dropped on its own — this helper only strips arguments, it does + /// not decide whether a trailing flag is an error (see + /// 's present output for that). + internal static string[] WithoutFlagAndValue(string[] arguments, string flag) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentException.ThrowIfNullOrWhiteSpace(flag); + + var result = new List(arguments.Length); + for (int i = 0; i < arguments.Length; i++) + { + if (string.Equals(arguments[i], flag, StringComparison.Ordinal)) + { + i++; // also skip the flag's value, if any + continue; + } + result.Add(arguments[i]); + } + return [.. result]; + } +} diff --git a/src/AcDream.App/Configuration/SessionConfiguration.cs b/src/AcDream.App/Configuration/SessionConfiguration.cs index 313f75ea..2a0359be 100644 --- a/src/AcDream.App/Configuration/SessionConfiguration.cs +++ b/src/AcDream.App/Configuration/SessionConfiguration.cs @@ -42,6 +42,26 @@ internal sealed class SessionConfiguration internal sealed class SessionProcessSettings { public SessionContentDescriptor? Content { get; init; } + + /// Campaign LA slice LA1 review fix (F2): accepted so the SAME + /// document also satisfies the Headless loader's own + /// process.paths member (HeadlessPathOverrides) — parsed + /// and ignored here, exactly like + /// and below. App has + /// no config/data/cache directory override concept of its own (those + /// come from ApplicationPathSet/env vars on this host); only the + /// Headless host consumes overrides composed under this key. + public SessionProcessPathOverrides? Paths { get; init; } +} + +/// Accepted-but-ignored mirror of Headless's +/// HeadlessPathOverrides shape — see +/// . +internal sealed class SessionProcessPathOverrides +{ + public string? ConfigDirectory { get; init; } + public string? DataDirectory { get; init; } + public string? CacheDirectory { get; init; } } internal sealed class SessionContentDescriptor @@ -75,6 +95,19 @@ internal sealed record SessionDescriptor /// App has no bot-policy concept. public SessionPolicyDescriptor? Policy { get; init; } + /// Campaign LA slice LA1 review fix (F2): pinned-contract + /// mode discriminator. ABSENT means today's ONLY App behavior — an + /// ordinary play session — so every document written before this field + /// existed keeps parsing unchanged. "probe" (LA2's connect + /// ▸ characterList ▸ graceful-disconnect flow, no EnterWorld) is + /// HEADLESS-ONLY; the App loader rejects it with an explicit message + /// naming the field rather than the caller ever seeing a raw unmapped- + /// member . Any other value + /// is a configuration error — the pinned contract defines no other + /// mode literal, so a document is either silent about mode (play) or + /// says "probe" exactly. + public string? Mode { get; init; } + [JsonRequired] public SessionCredentialDescriptor Credential { get; init; } = new(); diff --git a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs index e26aa806..086cb11e 100644 --- a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs +++ b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs @@ -154,5 +154,31 @@ internal static class SessionConfigurationLoader throw new SessionConfigurationException( $"Session '{session.Id}' statusFile must be a non-empty path when present."); } + + ValidateMode(session); + } + + /// + /// Campaign LA slice LA1 review fix (F2): mode is Headless-only + /// on the App host — the graphical host has no probe concept (LA2 + /// builds the probe in Headless only). An absent field is today's ONLY + /// App behavior (play); "probe" gets a specific, actionable + /// message instead of a cryptic unmapped-member JSON error; anything + /// else is a plain configuration error. + /// + private static void ValidateMode(SessionDescriptor session) + { + if (session.Mode is null) + return; + + if (string.Equals(session.Mode, "probe", StringComparison.Ordinal)) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' has mode 'probe'; probe sessions " + + "are headless-only and cannot run on the graphical host."); + } + + throw new SessionConfigurationException( + $"Session '{session.Id}' has unsupported mode '{session.Mode}'."); } } diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 61ed733a..52e647be 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -38,8 +38,22 @@ Log.Information( // existing one positional dat-dir argument and every ACDREAM_* env var keep // working exactly as before when the flag is absent. See // docs/plans/2026-08-14-launcher-campaign.md LA1. -string? sessionConfigFlagPath = ExtractFlagValue(args, "--session-config"); -string[] positionalArgs = WithoutFlagAndValue(args, "--session-config"); +// +// Review fix F5 (LA1 review round): a trailing, valueless --session-config +// (the flag typed as the LAST argument, nothing after it) must be a hard +// error, never a silent fall-through to the env-var path — a launcher that +// mis-composed its argv would otherwise appear to work while quietly +// ignoring the session-config contract entirely. +string? sessionConfigFlagPath = SessionConfigArgumentParsing.ExtractFlagValue( + args, "--session-config", out bool sessionConfigFlagPresent); +if (sessionConfigFlagPath is null && sessionConfigFlagPresent) +{ + Log.Error( + "--session-config requires a value (a path to the session-config document)."); + return 2; +} +string[] positionalArgs = + SessionConfigArgumentParsing.WithoutFlagAndValue(args, "--session-config"); var datDirArg = positionalArgs.FirstOrDefault(); var envDatDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); @@ -240,34 +254,9 @@ finally return 0; -// Campaign LA slice LA1: --session-config parsing helpers. Kept -// local/minimal rather than a general-purpose CLI parser — App has exactly -// one optional flag-with-value today; the positional dat-dir argument must -// stay untouched by its presence (see the comment above the flag parse). -static string? ExtractFlagValue(string[] arguments, string flag) -{ - for (int i = 0; i < arguments.Length - 1; i++) - { - if (string.Equals(arguments[i], flag, StringComparison.Ordinal)) - return arguments[i + 1]; - } - return null; -} - -static string[] WithoutFlagAndValue(string[] arguments, string flag) -{ - var result = new List(arguments.Length); - for (int i = 0; i < arguments.Length; i++) - { - if (string.Equals(arguments[i], flag, StringComparison.Ordinal)) - { - i++; // also skip the flag's value - continue; - } - result.Add(arguments[i]); - } - return [.. result]; -} - +// Campaign LA slice LA1: --session-config value-presence helper. The +// flag/positional-argument extraction itself lives in +// AcDream.App.Configuration.SessionConfigArgumentParsing (review fix F5) so +// its trailing-flag edge case is unit testable. static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs index b2260453..6ccb27fe 100644 --- a/src/AcDream.Runtime/Session/SessionStatusWriter.cs +++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs @@ -33,6 +33,49 @@ namespace AcDream.Runtime.Session; /// event method below takes only identifiers, names, and counts — there is no /// parameter shape that could carry a password, by construction. /// +/// +/// +/// This writer can never fail or stall the session transaction it +/// observes (Campaign LA LA1 review fix F1). Every call site sits +/// inside a caller-owned try block that treats a throw as a real failure — +/// LiveSessionController.StartCore's connect/roster/enter-world +/// sequence, SessionStartCompositionPhase.Start (which calls +/// BEFORE Session.Start even runs), +/// GameWindow.CompleteShutdown (which calls +/// BEFORE PublishShutdownRoots, so a throw would skip graceful +/// teardown entirely), and HeadlessSessionHost.Dispose's stage machine +/// (a throw from stage 8's call leaves +/// _disposeStage unadvanced and _disposed unset forever — a +/// permanently un-disposable host). An observability sink that can fail the +/// transaction it is merely reporting on is a defect in the sink, not a +/// reason for every call site to defend itself — so every exception this +/// class's own I/O can raise (a missing parent directory on a fresh cache +/// dir, a path segment that collides with an existing file, a permissions +/// error, a network path some future caller supplies) is caught here, logged +/// once to stderr, and LATCHES the writer into a permanent no-op — the exact +/// same "cheap null-check forever after" shape a never-configured path +/// already gets. The parent directory is created lazily, once, on the first +/// write, inside the same protection, so a fresh +/// .../launcher/sessions/<id>/status.jsonl path (whose directory +/// does not exist yet) is the expected first-run case, not a failure. +/// +/// +/// +/// Latency posture: every write is a synchronous local-disk +/// file open + line append + flush + close on the calling thread — there is +/// no batching, no background writer, no async path. This is fine for the +/// low-frequency lifecycle events this class carries (at most a handful per +/// second even under LA5/LA6 plugin/login-command load) against a local +/// disk. A statusFile path that resolves to a network location (a +/// UNC share, a mapped network drive, a FUSE mount with high per-syscall +/// latency) is UNSUPPORTED BY DESIGN — every event write would block the +/// session transaction's calling thread for the round-trip, and a slow or +/// wedged network path would eventually get caught by the same catch clause +/// that handles a missing directory and latch off, silently dropping the +/// rest of that session's status stream. Callers that need a status stream +/// over the network should tail the local file with a separate process, +/// never point statusFile at a network path directly. +/// /// public sealed class SessionStatusWriter { @@ -46,6 +89,8 @@ public sealed class SessionStatusWriter private readonly string? _path; private readonly TimeProvider _timeProvider; private readonly object _gate = new(); + private bool _directoryEnsured; + private bool _latchedOff; public SessionStatusWriter(string? path, TimeProvider? timeProvider = null) { @@ -54,12 +99,13 @@ public sealed class SessionStatusWriter } /// - /// True when this writer has a configured path and will actually append - /// events. Lets a caller with an expensive report to build (e.g. the - /// roster projection) skip that work entirely when nobody configured a - /// status file for this session. + /// True when this writer has a configured path and has not latched + /// itself off after a failed write. Lets a caller with an expensive + /// report to build (e.g. the roster projection) skip that work entirely + /// when nobody configured a status file for this session, or when this + /// writer already gave up after an I/O failure. /// - public bool IsEnabled => _path is not null; + public bool IsEnabled => _path is not null && !_latchedOff; public void Started(string sessionId) => Write(new @@ -143,20 +189,71 @@ public sealed class SessionStatusWriter private void Write(T value) { - if (_path is not { } path) + if (_path is not { } path || _latchedOff) return; - string line = JsonSerializer.Serialize(value, JsonOptions); lock (_gate) { - using FileStream stream = new( - path, - FileMode.Append, - FileAccess.Write, - FileShare.Read); - using var writer = new StreamWriter(stream); - writer.WriteLine(line); - writer.Flush(); + // Re-check inside the lock: another thread may have latched the + // writer off (or already ensured the directory) between the + // fast check above and taking the gate. + if (_latchedOff) + return; + + try + { + EnsureDirectory(path); + string line = JsonSerializer.Serialize(value, JsonOptions); + using FileStream stream = new( + path, + FileMode.Append, + FileAccess.Write, + FileShare.Read); + using var writer = new StreamWriter(stream); + writer.WriteLine(line); + writer.Flush(); + } + catch (Exception error) when (IsRecoverableIoFailure(error)) + { + LatchOff(path, error); + } } } + + private void EnsureDirectory(string path) + { + if (_directoryEnsured) + return; + + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + _directoryEnsured = true; + } + + private void LatchOff(string path, Exception error) + { + _latchedOff = true; + Console.Error.WriteLine( + $"[status-writer] disabling status stream at '{path}' after a " + + $"write failure ({error.GetType().Name}: {error.Message}); no " + + "further events for this session will be written."); + } + + /// + /// The set of exceptions this class's own file I/O can plausibly raise + /// — a missing parent directory, a path segment colliding with an + /// existing file, permission failures, an unsupported path shape, or a + /// platform security restriction. Anything outside this set (e.g. an + /// ) is deliberately NOT caught — + /// this class only promises to survive ITS OWN recoverable I/O + /// failures, never to become a blanket exception sink. + /// + private static bool IsRecoverableIoFailure(Exception error) => + error is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException + or System.Security.SecurityException + or DirectoryNotFoundException; } diff --git a/tests/AcDream.App.Tests/Configuration/SessionConfigArgumentParsingTests.cs b/tests/AcDream.App.Tests/Configuration/SessionConfigArgumentParsingTests.cs new file mode 100644 index 00000000..1aed83f0 --- /dev/null +++ b/tests/AcDream.App.Tests/Configuration/SessionConfigArgumentParsingTests.cs @@ -0,0 +1,97 @@ +using AcDream.App.Configuration; + +namespace AcDream.App.Tests.Configuration; + +/// +/// Campaign LA slice LA1 review fix (F5): pins +/// 's trailing-flag edge case — +/// --session-config present as the LAST argument with nothing after +/// it must be distinguishable from the flag being entirely absent, so +/// Program.cs can turn it into a hard error instead of a silent +/// fall-through to the env-var/positional dat-dir path. +/// +public sealed class SessionConfigArgumentParsingTests +{ + private const string Flag = "--session-config"; + + [Fact] + public void FlagWithAFollowingValueReturnsThatValueAndIsPresent() + { + string? value = SessionConfigArgumentParsing.ExtractFlagValue( + ["D:\\dats", Flag, "session.json"], + Flag, + out bool present); + + Assert.True(present); + Assert.Equal("session.json", value); + } + + [Fact] + public void FlagAbsentReturnsNullAndIsNotPresent() + { + string? value = SessionConfigArgumentParsing.ExtractFlagValue( + ["D:\\dats"], + Flag, + out bool present); + + Assert.False(present); + Assert.Null(value); + } + + [Fact] + public void TrailingFlagWithNoValueIsPresentWithANullValue() + { + string? value = SessionConfigArgumentParsing.ExtractFlagValue( + ["D:\\dats", Flag], + Flag, + out bool present); + + // This is the case Program.cs must turn into exit code 2 — present + // but no value is categorically different from "not present at + // all", even though both currently yield a null return value. + Assert.True(present); + Assert.Null(value); + } + + [Fact] + public void FlagAloneAsTheOnlyArgumentIsPresentWithANullValue() + { + string? value = SessionConfigArgumentParsing.ExtractFlagValue( + [Flag], + Flag, + out bool present); + + Assert.True(present); + Assert.Null(value); + } + + [Fact] + public void WithoutFlagAndValueDropsTheFlagAndItsValue() + { + string[] positional = SessionConfigArgumentParsing.WithoutFlagAndValue( + ["D:\\dats", Flag, "session.json", "extra"], + Flag); + + Assert.Equal(["D:\\dats", "extra"], positional); + } + + [Fact] + public void WithoutFlagAndValueTrailingFlagDropsOnlyTheFlagItself() + { + string[] positional = SessionConfigArgumentParsing.WithoutFlagAndValue( + ["D:\\dats", Flag], + Flag); + + Assert.Equal(["D:\\dats"], positional); + } + + [Fact] + public void WithoutFlagAndValueLeavesArgumentsUnchangedWhenFlagIsAbsent() + { + string[] positional = SessionConfigArgumentParsing.WithoutFlagAndValue( + ["D:\\dats"], + Flag); + + Assert.Equal(["D:\\dats"], positional); + } +} diff --git a/tests/AcDream.App.Tests/Configuration/SessionConfigurationLoaderTests.cs b/tests/AcDream.App.Tests/Configuration/SessionConfigurationLoaderTests.cs new file mode 100644 index 00000000..54e64408 --- /dev/null +++ b/tests/AcDream.App.Tests/Configuration/SessionConfigurationLoaderTests.cs @@ -0,0 +1,140 @@ +using AcDream.App.Configuration; + +namespace AcDream.App.Tests.Configuration; + +/// +/// Campaign LA slice LA1 review fix (F2): the App session-config reader +/// must TOLERATE the two document shapes only the Headless side of the +/// pinned contract currently defines meaning for — process.paths +/// (HeadlessPathOverrides) and the per-session mode +/// discriminator (LA2's probe flow) — so a launcher-composed document does +/// not throw a raw unmapped-member +/// on the App host. See docs/plans/2026-08-14-launcher-campaign.md +/// LA1's pinned contract. +/// +public sealed class SessionConfigurationLoaderTests +{ + [Fact] + public void ProcessPathsAreAcceptedButIgnored() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "process": { + "paths": { + "configDirectory": "/config", + "dataDirectory": "/data", + "cacheDirectory": "/cache" + } + }, + "sessions": [ + { + "id": "paths-tolerant", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "X" } + } + ] + } + """); + + (SessionConfiguration configuration, SessionDescriptor session) = + SessionConfigurationLoader.Load(file.Path); + + Assert.Equal("paths-tolerant", session.Id); + Assert.Equal("/config", configuration.Process?.Paths?.ConfigDirectory); + Assert.Equal("/data", configuration.Process?.Paths?.DataDirectory); + Assert.Equal("/cache", configuration.Process?.Paths?.CacheDirectory); + } + + [Fact] + public void AbsentModeIsTreatedAsPlay() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "no-mode", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "X" } + } + ] + } + """); + + (_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path); + + Assert.Null(session.Mode); + } + + [Fact] + public void ProbeModeFailsLoadWithAnExplicitHeadlessOnlyMessage() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "probe-session", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "X" }, + "mode": "probe" + } + ] + } + """); + + SessionConfigurationException error = Assert.Throws( + () => SessionConfigurationLoader.Load(file.Path)); + Assert.Contains("mode", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("probe", error.Message, StringComparison.Ordinal); + Assert.Contains("headless-only", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void UnrecognizedModeFailsLoad() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "bad-mode", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "X" }, + "mode": "bogus" + } + ] + } + """); + + Assert.Throws( + () => SessionConfigurationLoader.Load(file.Path)); + } + + private sealed class TemporaryFile : IDisposable + { + private TemporaryFile(string path) => Path = path; + + internal string Path { get; } + + internal static TemporaryFile Create(string json) + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-app-la1-loader-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, json); + return new TemporaryFile(path); + } + + public void Dispose() => File.Delete(Path); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs index 5b9e7133..c1bfc020 100644 --- a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs @@ -97,8 +97,22 @@ public sealed class SessionStatusWriterTests writer.Started("s1"); } + /// + /// F7 (Campaign LA LA1 review fix round): replaces the earlier + /// "DoesNotContain 'hunter2'/'password'" assertion, which could never + /// actually fail — no writer method below accepts a credential-shaped + /// parameter in the first place, so the absence of those literal strings + /// proved nothing about the SHAPE of what gets serialized. This test + /// asserts the structural claim that actually backs the "never write + /// credential material into this stream" contract: each event kind + /// serializes EXACTLY its pinned property set — the shared envelope + /// (v/e/t/sessionId) plus that event's own + /// named fields, nothing else. An extra property (a smuggled password, + /// or any other accidental field) fails this test by construction, + /// regardless of what value it carries. + /// [Fact] - public void PasswordNeverAppearsInTheStatusStream() + public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse() { using TemporaryFile file = TemporaryFile.Create(); var writer = new SessionStatusWriter(file.Path); @@ -115,9 +129,110 @@ public sealed class SessionStatusWriterTests writer.Disconnected("bot", "stopped"); writer.Exited("bot", 0, "disposed"); - string contents = File.ReadAllText(file.Path); - Assert.DoesNotContain("hunter2", contents, StringComparison.Ordinal); - Assert.DoesNotContain("password", contents, StringComparison.OrdinalIgnoreCase); + string[] lines = File.ReadAllLines(file.Path); + Assert.Equal(6, lines.Length); + + AssertExactProperties(lines[0], "v", "e", "t", "sessionId"); + AssertExactProperties(lines[1], "v", "e", "t", "sessionId"); + AssertExactProperties( + lines[2], + "v", "e", "t", "sessionId", "accountName", "slotCount", "characters"); + AssertExactProperties( + lines[3], "v", "e", "t", "sessionId", "characterId", "characterName"); + AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason"); + AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason"); + + // The nested characters[] entries are exact too — the exact shape a + // password could otherwise be smuggled through. + JsonElement character = Parse(lines[2]).GetProperty("characters")[0]; + AssertExactProperties(character, "id", "name", "secondsGreyedOut"); + } + + private static void AssertExactProperties(string line, params string[] expected) => + AssertExactProperties(Parse(line), expected); + + private static void AssertExactProperties(JsonElement element, params string[] expected) + { + string[] actual = element.EnumerateObject() + .Select(static property => property.Name) + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + string[] sortedExpected = expected + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + Assert.Equal(sortedExpected, actual); + } + + /// + /// F1 (Campaign LA LA1 review fix round): a status file whose parent + /// directory does not exist yet — the expected first-run shape of + /// .../launcher/sessions/<id>/status.jsonl on a fresh cache + /// dir — must be created lazily rather than throwing + /// out of the transaction the + /// writer is merely observing. + /// + [Fact] + public void MissingParentDirectoryIsCreatedAndEventsFlow() + { + string root = Path.Combine( + Path.GetTempPath(), + $"acdream-status-root-{Guid.NewGuid():N}"); + string path = Path.Combine(root, "nested", "sessions", "s1", "status.jsonl"); + try + { + Assert.False(Directory.Exists(Path.GetDirectoryName(path))); + var writer = new SessionStatusWriter(path); + + writer.Started("s1"); + writer.Connected("s1"); + + Assert.True(writer.IsEnabled); + string[] lines = File.ReadAllLines(path); + Assert.Equal(2, lines.Length); + Assert.Contains("\"started\"", lines[0]); + Assert.Contains("\"connected\"", lines[1]); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + /// + /// F1: a path whose PARENT SEGMENT already exists as an ordinary file + /// (so cannot turn it into a + /// directory) is exactly the "unwritable path" case the review asked + /// for — the writer must latch itself off instead of throwing, and every + /// subsequent call must stay a cheap no-op. + /// + [Fact] + public void ParentSegmentIsAFileLatchesTheWriterInsteadOfThrowing() + { + string blocker = Path.Combine( + Path.GetTempPath(), + $"acdream-status-blocker-{Guid.NewGuid():N}"); + File.WriteAllText(blocker, "not a directory"); + string path = Path.Combine(blocker, "status.jsonl"); + try + { + var writer = new SessionStatusWriter(path); + Assert.True(writer.IsEnabled); + + // Must not throw — the writer swallows its own I/O failure and + // latches off instead of failing the caller's transaction. + writer.Started("s1"); + Assert.False(writer.IsEnabled); + + // Latched-off calls stay cheap no-ops — no exception, no retry. + writer.Connected("s1"); + writer.Exited("s1", 0, "disposed"); + } + finally + { + if (File.Exists(blocker)) + File.Delete(blocker); + } } [Fact] diff --git a/tests/Fixtures/campaign-la/session-config-shared-fixture.json b/tests/Fixtures/campaign-la/session-config-shared-fixture.json index 822921a6..24ec55a1 100644 --- a/tests/Fixtures/campaign-la/session-config-shared-fixture.json +++ b/tests/Fixtures/campaign-la/session-config-shared-fixture.json @@ -1,5 +1,11 @@ { "version": 1, + "process": { + "content": { + "datDirectory": "shared-fixture-dats", + "preparedAssetPath": "shared-fixture-dats/acdream.pak" + } + }, "sessions": [ { "id": "shared-fixture", @@ -8,8 +14,8 @@ "character": { "name": "SharedToon" }, "policy": { "id": "idle" }, "credential": { - "provider": "environment", - "reference": "SHARED_FIXTURE_PASSWORD" + "provider": "standardInput", + "reference": "session" }, "plugins": ["ExamplePlugin", "AnotherPlugin"], "loginCommands": ["/tell someone, hi", "/vt start"],