acdream/src/AcDream.Runtime/Session/SessionStatusWriter.cs
Erik e77ebf100f CC2 review fix round: latch scope narrowed, AD-100, creationFailed reason key
F1 (MEDIUM): the correlation-latch docs claimed replies are never
misattributed; in truth an overlapping send OVERWRITES the latch and the
first reply routes to the newest request's event. Narrowed all three doc
sites to the exact contract (single outstanding request; overlap refusal
is CC3's Runtime verification gate, retail's DoFinish UNDEF-state rule)
and pinned the overwrite behavior with
OverlappingSend_OverwritesTheLatch_ReplyRoutesToNewestRequest.

F2 (LOW): filed register AD-100 for the drop-unless-armed deviation —
retail's Handle_CharGenVerificationResponse@0x0055E8B0 has no armed gate
and processes whatever arrives against its persistent verification state.

F3 (LOW): doc note in CharacterCreate.cs — ACE double-sends NameInUse
(IsCharacterNameAvailable runs twice; the first callback's return exits
only the lambda), so the second reply hitting the drop path during a
connected gate is EXPECTED, not a defect.

F4 (LOW): creationFailed's enum-member key renamed name -> reason and the
ATTEMPTED character name added as name, before any consumer shipped —
one status vocabulary must not give the same key two meanings
(characterCreated.name is a character name). Contract, writer, tailer,
and shape-pinning tests updated in lockstep.

F5 (LOW): the thread-id probe-note pointer now cites
ProbeNetLogOutbound's doc comment, where the note actually lives.

Fidelity fold (reviewer's positive note): the latch is retail's OWN
discriminator one layer down — 0x0055E8B0 case 1 branches on
GetVerificationState()==PENDING (create) vs not (restore) — now cited in
both the latch doc and CharGenVerificationResponse.cs.

Core.Net 994, Runtime 1667, Launcher.Core 324, all green Release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 13:10:24 +02:00

460 lines
16 KiB
C#

using System.Text.Json;
namespace AcDream.Runtime.Session;
/// <summary>
/// Campaign LA slice LA1: appends one JSON object per line to a per-session
/// status-event file the launcher tails
/// (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1,
/// <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c> §6).
///
/// <para>
/// This is a SEPARATE sink from <c>HeadlessDiagnosticWriter</c> — that class
/// is a single shared-stdout JSONL diagnostics stream with no per-session
/// file; this class writes one file per session, meant to be read by an
/// external process (the launcher) rather than scraped from console output.
/// Event shapes are versioned (<c>"v":1</c>); LA5's
/// <c>pluginLoaded</c>/<c>pluginFailed</c> additions use that same envelope
/// without breaking an existing reader.
/// </para>
///
/// <para>
/// Every write opens the file in append mode with <see cref="FileShare.Read"/>
/// so an external tailer can read the file concurrently, writes exactly one
/// line, flushes, and closes — there is no long-lived file handle to leak or
/// to dispose. A writer constructed with a <see langword="null"/> or blank
/// path is a permanent no-op: every method becomes a cheap null-check, so
/// callers never need to guard construction sites on whether a status file
/// was configured.
/// </para>
///
/// <para>
/// <strong>Never write credential material into this stream.</strong> LA5's
/// <see cref="PluginFailed"/> diagnostic is caller-supplied text, so hosts may
/// pass only the plugin lifecycle failure and must never append session
/// credentials or other secrets. Neither plugin host exposes credentials
/// through <c>IPluginHost</c>.
/// </para>
///
/// <para>
/// The writer also owns the small amount of stream-ordering state needed to
/// keep the external contract coherent across host implementations. A second
/// <c>connected</c> edge while the prior connection is still open first emits
/// <c>disconnected(reason: "reconnect")</c>; a terminal <c>exited</c> edge
/// closes any still-open connection with
/// <c>disconnected(reason: "process-exit")</c>. <c>exited</c> is terminal and
/// idempotent: the first call wins and every later event is ignored. This is
/// deliberately enforced here because both graphical and no-window hosts use
/// this exact sink, while their reconnect command adapters are separate.
/// </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/&lt;id&gt;/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
{
private const int VocabularyVersion = 1;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private readonly string? _path;
private readonly TimeProvider _timeProvider;
private readonly object _gate = new();
private bool _directoryEnsured;
private bool _latchedOff;
private bool _connected;
private bool _exited;
public SessionStatusWriter(string? path, TimeProvider? timeProvider = null)
{
_path = string.IsNullOrWhiteSpace(path) ? null : Path.GetFullPath(path);
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// 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 && !_latchedOff;
public void Started(string sessionId) =>
Write(new
{
v = VocabularyVersion,
e = "started",
t = Now(),
sessionId,
});
public void Connected(string sessionId)
{
if (!IsEnabled)
return;
lock (_gate)
{
if (_latchedOff || _exited)
return;
if (_connected)
{
if (!TryWriteLocked(new
{
v = VocabularyVersion,
e = "disconnected",
t = Now(),
sessionId,
reason = "reconnect",
}))
{
return;
}
_connected = false;
}
if (TryWriteLocked(new
{
v = VocabularyVersion,
e = "connected",
t = Now(),
sessionId,
}))
{
_connected = true;
}
}
}
public void CharacterList(string sessionId, LiveSessionRosterReport roster)
{
ArgumentNullException.ThrowIfNull(roster);
if (!IsEnabled)
return;
Write(new
{
v = VocabularyVersion,
e = "characterList",
t = Now(),
sessionId,
accountName = roster.AccountName,
slotCount = roster.SlotCount,
characters = roster.Entries
.Select(static entry => new
{
id = entry.Id,
name = entry.Name,
secondsGreyedOut = entry.SecondsGreyedOut,
})
.ToArray(),
});
}
public void EnteredWorld(string sessionId, uint characterId, string characterName) =>
Write(new
{
v = VocabularyVersion,
e = "enteredWorld",
t = Now(),
sessionId,
characterId,
characterName,
});
/// <summary>
/// Campaign CC CC2: the retail <c>0xF643</c> Ok response to an outbound
/// CharacterCreate — see
/// <c>AcDream.Core.Net.Messages.CharGenVerificationResponse</c>. <paramref name="guid"/>
/// and <paramref name="name"/> come straight off that response's Ok
/// identity payload. This is a distinct event from <see cref="EnteredWorld"/>:
/// retail logs a freshly created character straight in without a fresh
/// CharacterList (see the shared response type's doc comment), so a
/// caller can expect this event to precede an eventual
/// <see cref="EnteredWorld"/> for the same character, not replace it.
/// </summary>
public void CharacterCreated(string sessionId, uint guid, string name) =>
Write(new
{
v = VocabularyVersion,
e = "characterCreated",
t = Now(),
sessionId,
guid,
name,
});
/// <summary>
/// Campaign CC CC2: a non-Ok <c>0xF643</c> response to an outbound
/// CharacterCreate. <paramref name="code"/> is the raw wire
/// <c>CharGenVerificationResponse.Code</c> value; <paramref name="reason"/>
/// is that code's enum member name (e.g. <c>"NameInUse"</c>) so a
/// launcher can render a readable reason without hard-coding the
/// server's numeric-to-dialog mapping itself; <paramref name="name"/>
/// is the ATTEMPTED character name — the thing a launcher most wants to
/// show ("the name Bob is taken"). The key was <c>name</c> for the enum
/// member until the CC2 review (F4): <c>characterCreated.name</c> is a
/// character name, and one status vocabulary must not give the same key
/// two meanings. Renamed before any consumer shipped.
/// </summary>
public void CreationFailed(string sessionId, uint code, string reason, string name) =>
Write(new
{
v = VocabularyVersion,
e = "creationFailed",
t = Now(),
sessionId,
code,
reason,
name,
});
public void PluginLoaded(string sessionId, string plugin) =>
Write(new
{
v = VocabularyVersion,
e = "pluginLoaded",
t = Now(),
sessionId,
plugin,
});
public void PluginFailed(string sessionId, string plugin, string error) =>
Write(new
{
v = VocabularyVersion,
e = "pluginFailed",
t = Now(),
sessionId,
plugin,
error,
});
public void LoginCommandFailed(
string sessionId,
int commandIndex,
string command,
string error) =>
Write(new
{
v = VocabularyVersion,
e = "loginCommandFailed",
t = Now(),
sessionId,
commandIndex,
command,
error,
});
public void Disconnected(string sessionId, string reason)
{
if (!IsEnabled)
return;
lock (_gate)
{
if (_latchedOff || _exited)
return;
if (TryWriteLocked(new
{
v = VocabularyVersion,
e = "disconnected",
t = Now(),
sessionId,
reason,
}))
{
_connected = false;
}
}
}
public void Exited(string sessionId, int code, string reason)
{
if (!IsEnabled)
return;
lock (_gate)
{
if (_latchedOff || _exited)
return;
if (_connected)
{
if (!TryWriteLocked(new
{
v = VocabularyVersion,
e = "disconnected",
t = Now(),
sessionId,
reason = "process-exit",
}))
{
return;
}
_connected = false;
}
if (TryWriteLocked(new
{
v = VocabularyVersion,
e = "exited",
t = Now(),
sessionId,
code,
reason,
}))
{
_exited = true;
}
}
}
private string Now() =>
_timeProvider.GetUtcNow().ToString(
"O",
System.Globalization.CultureInfo.InvariantCulture);
private void Write<T>(T value)
{
if (_path is null || _latchedOff)
return;
lock (_gate)
{
// 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 || _exited)
return;
_ = TryWriteLocked(value);
}
}
/// <summary>
/// Writes one event while <see cref="_gate"/> is held. Returning success
/// lets the lifecycle methods publish their state transition only after
/// the matching line has reached the stream. A recoverable I/O failure
/// latches the writer off, so there is never a retry that could duplicate
/// an uncertain terminal edge.
/// </summary>
private bool TryWriteLocked<T>(T value)
{
string path = _path!;
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();
return true;
}
catch (Exception error) when (IsRecoverableIoFailure(error))
{
LatchOff(path, error);
return false;
}
}
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;
try
{
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.");
}
catch (Exception diagnosticError)
when (IsRecoverableIoFailure(diagnosticError)
|| diagnosticError is ObjectDisposedException
or InvalidOperationException)
{
// This is the fallback diagnostic for an already-failed
// observability sink. A closed/broken stderr must not turn it
// back into a session-transaction failure.
}
}
/// <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;
}