413 lines
14 KiB
C#
413 lines
14 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/<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
|
|
{
|
|
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,
|
|
});
|
|
|
|
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;
|
|
}
|