fix(launcher): close Campaign LA LA1 review findings
This commit is contained in:
parent
4edc122085
commit
d511e4c348
12 changed files with 413 additions and 65 deletions
|
|
@ -29,11 +29,12 @@ internal sealed class AppCredentialResolver
|
|||
private readonly bool _isLinux;
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="isLinux"/> is caller-supplied, never detected in this
|
||||
/// file — <c>LinuxPlatformBoundaryTests</c>'s platform-owner guard
|
||||
/// requires every OS-family check to live under <c>Platform/</c>;
|
||||
/// callers pass <c>GraphicalHostPlatformServices</c>'s already-detected
|
||||
/// value instead of this file re-detecting it itself.
|
||||
/// <paramref name="isLinux"/> is the caller-supplied platform-policy
|
||||
/// value from <c>GraphicalHostPlatformServices</c>. This file still uses
|
||||
/// <c>RuntimePlatformGuard.IsLinuxRuntime</c> below as the narrow
|
||||
/// CA1416-recognized runtime guard required before calling
|
||||
/// <c>File.GetUnixFileMode</c>; it does not independently select the host
|
||||
/// platform or bypass the platform-services owner.
|
||||
/// </summary>
|
||||
internal AppCredentialResolver(
|
||||
TextReader standardInput,
|
||||
|
|
|
|||
|
|
@ -1665,7 +1665,7 @@ public sealed class GameWindow :
|
|||
// OnClosing() native-window-close-request pass) represents the
|
||||
// process actually being done.
|
||||
if (releaseNativeWindow)
|
||||
_statusWriter.Exited(_options.SessionId ?? "app", 0, "disposed");
|
||||
_statusWriter.Exited(_options.SessionId ?? "app", 0, "graceful");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using AcDream.App.Configuration;
|
||||
using AcDream.App.Rendering.Residency;
|
||||
using AcDream.App.Streaming;
|
||||
|
|
@ -266,6 +268,44 @@ public sealed record RuntimeOptions(
|
|||
selector.Id,
|
||||
selector.Name);
|
||||
|
||||
private static readonly PropertyInfo[] PrintableProperties =
|
||||
typeof(RuntimeOptions)
|
||||
.GetProperties(
|
||||
BindingFlags.Instance
|
||||
| BindingFlags.Public
|
||||
| BindingFlags.DeclaredOnly)
|
||||
.Where(static property =>
|
||||
property.GetMethod is not null
|
||||
&& property.GetIndexParameters().Length == 0)
|
||||
.OrderBy(static property => property.MetadataToken)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA LA1 defense in depth: positional records normally print
|
||||
/// every public property, including the live password. Preserve that
|
||||
/// ordinary diagnostic property set while substituting the one sensitive
|
||||
/// value before it can reach a log, debugger display, or exception.
|
||||
/// Reflection is cached once and runs only on the diagnostic
|
||||
/// <see cref="object.ToString"/> path.
|
||||
/// </summary>
|
||||
private bool PrintMembers(StringBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
for (int index = 0; index < PrintableProperties.Length; index++)
|
||||
{
|
||||
PropertyInfo property = PrintableProperties[index];
|
||||
if (index != 0)
|
||||
builder.Append(", ");
|
||||
builder.Append(property.Name);
|
||||
builder.Append(" = ");
|
||||
builder.Append(
|
||||
property.Name == nameof(LivePass) && LivePass is not null
|
||||
? "<redacted>"
|
||||
: property.GetValue(this));
|
||||
}
|
||||
return PrintableProperties.Length != 0;
|
||||
}
|
||||
|
||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||
public bool HasLiveCredentials =>
|
||||
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
|
||||
|
|
|
|||
|
|
@ -491,8 +491,9 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_policy.Tick(Runtime, Commands);
|
||||
}
|
||||
|
||||
internal RuntimeTeardownAcknowledgement Stop()
|
||||
internal RuntimeTeardownAcknowledgement Stop(string reason = "stopped")
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
|
||||
RuntimeTeardownAcknowledgement result =
|
||||
Commands.Session.Stop(Runtime.Generation);
|
||||
// R9 review fix (2026-08-03): _currentSession is cached across
|
||||
|
|
@ -510,7 +511,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
if (_hasConnected)
|
||||
{
|
||||
_hasConnected = false;
|
||||
_statusWriter.Disconnected(_descriptor.Id, "stopped");
|
||||
_statusWriter.Disconnected(_descriptor.Id, reason);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -640,7 +641,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_statusWriter.Exited(
|
||||
_descriptor.Id,
|
||||
_faulted ? 1 : 0,
|
||||
_faulted ? "fault" : "disposed");
|
||||
_faulted ? "runtime-fault" : "graceful");
|
||||
_disposeStage++;
|
||||
_disposed = true;
|
||||
break;
|
||||
|
|
@ -670,8 +671,12 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
|
||||
if (reconnect)
|
||||
{
|
||||
RuntimeTeardownAcknowledgement stopped =
|
||||
_liveSession.Stop(expectedGeneration);
|
||||
// Campaign LA LA1 review fix F3: route reconnect teardown
|
||||
// through the same status-aware Stop boundary as every other
|
||||
// host stop. The retiring connection therefore publishes a
|
||||
// truthful disconnected(reason: "reconnect") edge before the
|
||||
// fresh LiveSessionHost reports its second connected edge.
|
||||
RuntimeTeardownAcknowledgement stopped = Stop("reconnect");
|
||||
if (!stopped.IsComplete)
|
||||
{
|
||||
return new RuntimeSessionStartResult(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue