wip: Campaign LA LA1 fix round — INCOMPLETE, stopped mid-task
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 <noreply@anthropic.com>
This commit is contained in:
parent
c9fc7f4a66
commit
75a6724d5b
9 changed files with 628 additions and 52 deletions
|
|
@ -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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>This writer can never fail or stall the session transaction it
|
||||
/// observes</strong> (Campaign LA LA1 review fix F1). Every call site sits
|
||||
/// inside a caller-owned try block that treats a throw as a real failure —
|
||||
/// <c>LiveSessionController.StartCore</c>'s connect/roster/enter-world
|
||||
/// sequence, <c>SessionStartCompositionPhase.Start</c> (which calls
|
||||
/// <see cref="Started"/> BEFORE <c>Session.Start</c> even runs),
|
||||
/// <c>GameWindow.CompleteShutdown</c> (which calls <see cref="Disconnected"/>
|
||||
/// BEFORE <c>PublishShutdownRoots</c>, so a throw would skip graceful
|
||||
/// teardown entirely), and <c>HeadlessSessionHost.Dispose</c>'s stage machine
|
||||
/// (a throw from stage 8's <see cref="Exited"/> call leaves
|
||||
/// <c>_disposeStage</c> unadvanced and <c>_disposed</c> 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
|
||||
/// <c>.../launcher/sessions/<id>/status.jsonl</c> path (whose directory
|
||||
/// does not exist yet) is the expected first-run case, not a failure.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Latency posture:</strong> 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 <c>statusFile</c> 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 <c>statusFile</c> at a network path directly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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>(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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="OutOfMemoryException"/>) is deliberately NOT caught —
|
||||
/// this class only promises to survive ITS OWN recoverable I/O
|
||||
/// failures, never to become a blanket exception sink.
|
||||
/// </summary>
|
||||
private static bool IsRecoverableIoFailure(Exception error) =>
|
||||
error is IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or ArgumentException
|
||||
or System.Security.SecurityException
|
||||
or DirectoryNotFoundException;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue