fix(launcher): close Campaign LA LA1 review findings

This commit is contained in:
Erik 2026-08-14 17:00:09 +02:00
parent 4edc122085
commit d511e4c348
12 changed files with 413 additions and 65 deletions

View file

@ -35,6 +35,18 @@ namespace AcDream.Runtime.Session;
/// </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 —
@ -91,6 +103,8 @@ public sealed class SessionStatusWriter
private readonly object _gate = new();
private bool _directoryEnsured;
private bool _latchedOff;
private bool _connected;
private bool _exited;
public SessionStatusWriter(string? path, TimeProvider? timeProvider = null)
{
@ -116,14 +130,44 @@ public sealed class SessionStatusWriter
sessionId,
});
public void Connected(string sessionId) =>
Write(new
public void Connected(string sessionId)
{
if (!IsEnabled)
return;
lock (_gate)
{
v = VocabularyVersion,
e = "connected",
t = Now(),
sessionId,
});
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)
{
@ -161,26 +205,70 @@ public sealed class SessionStatusWriter
characterName,
});
public void Disconnected(string sessionId, string reason) =>
Write(new
{
v = VocabularyVersion,
e = "disconnected",
t = Now(),
sessionId,
reason,
});
public void Disconnected(string sessionId, string reason)
{
if (!IsEnabled)
return;
public void Exited(string sessionId, int code, string reason) =>
Write(new
lock (_gate)
{
v = VocabularyVersion,
e = "exited",
t = Now(),
sessionId,
code,
reason,
});
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(
@ -189,7 +277,7 @@ public sealed class SessionStatusWriter
private void Write<T>(T value)
{
if (_path is not { } path || _latchedOff)
if (_path is null || _latchedOff)
return;
lock (_gate)
@ -197,26 +285,41 @@ public sealed class SessionStatusWriter
// 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)
if (_latchedOff || _exited)
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);
}
_ = 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;
}
}
@ -234,10 +337,22 @@ public sealed class SessionStatusWriter
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.");
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>