using System.Text.Json; namespace AcDream.Runtime.Session; /// /// Campaign LA slice LA1: appends one JSON object per line to a per-session /// status-event file the launcher tails /// (docs/plans/2026-08-14-launcher-campaign.md LA1, /// docs/superpowers/specs/2026-08-14-launcher-campaign-design.md §6). /// /// /// This is a SEPARATE sink from HeadlessDiagnosticWriter — 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 ("v":1); LA5's /// pluginLoaded/pluginFailed additions use that same envelope /// without breaking an existing reader. /// /// /// /// Every write opens the file in append mode with /// 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 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. /// /// /// /// Never write credential material into this stream. LA5's /// 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 IPluginHost. /// /// /// /// The writer also owns the small amount of stream-ordering state needed to /// keep the external contract coherent across host implementations. A second /// connected edge while the prior connection is still open first emits /// disconnected(reason: "reconnect"); a terminal exited edge /// closes any still-open connection with /// disconnected(reason: "process-exit"). exited 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. /// /// /// /// 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 { 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; } /// /// 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 && !_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, }); /// /// Campaign CC CC2: the retail 0xF643 Ok response to an outbound /// CharacterCreate — see /// AcDream.Core.Net.Messages.CharGenVerificationResponse. /// and come straight off that response's Ok /// identity payload. This is a distinct event from : /// 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 /// for the same character, not replace it. /// public void CharacterCreated(string sessionId, uint guid, string name) => Write(new { v = VocabularyVersion, e = "characterCreated", t = Now(), sessionId, guid, name, }); /// /// Campaign CC CC2: a non-Ok 0xF643 response to an outbound /// CharacterCreate. is the raw wire /// CharGenVerificationResponse.Code value; /// is that code's enum member name (e.g. "NameInUse") so a /// launcher can render a readable reason without hard-coding the /// server's numeric-to-dialog mapping itself; /// is the ATTEMPTED character name — the thing a launcher most wants to /// show ("the name Bob is taken"). The key was name for the enum /// member until the CC2 review (F4): characterCreated.name is a /// character name, and one status vocabulary must not give the same key /// two meanings. Renamed before any consumer shipped. /// 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 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); } } /// /// Writes one event while 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. /// private bool TryWriteLocked(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. } } /// /// 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; }