fix #406: launcher session exit observation carries the real code + captures client stderr

GameWindow.Dispose() (via Program.cs's `using var window = ...`) runs
unconditionally even when invoked mid-unwind of an exception that escaped
Run()'s Silk.NET frame loop. Resource teardown itself can converge
cleanly regardless, so CompleteShutdown had no way to tell "normal Run()
return" from "a crash is propagating through me right now" and always
wrote the hardcoded exited{code:0,reason:"graceful"} — exactly the
symptom #406 observed against a real 0xE0434352 crash. Fixed by latching
_runFailure in Run()'s existing catch block (before the pre-existing
throw) and consulting it from a new ReportExited method, the one call
site for the terminal status write: crashed(1)/graceful(0)/
shutdown-incomplete(1) as appropriate. No wire-contract amendment needed
— §LA1 pins the exited event NAME, and reason is already free text that
StatusEventParser round-trips unchanged.

Sibling gap fixed in the same commit: the launcher discarded the child's
stdout/stderr entirely, which is why diagnosing this exact crash required
a manual console re-run. Added BoundedProcessOutputCapture, a 2 MiB-capped
sink mirroring SessionStatusWriter's open-append-flush-close-per-write
posture (a long-lived write handle is not actually concurrently readable
on Windows even with FileShare.Read — confirmed by isolated repro), wired
into both SystemChildProcess (ProcessStartInfo.RedirectStandardError;
Linux + Windows graphical children, i.e. this bug's own scenario) and
WindowsSystemChildProcess (a real native pipe via CreateChildOutputPipe,
mirroring the existing stdin pipe; Windows console-capable/Headless
children). Opt-in via LauncherProcessSpec.StderrLogPath (null = unchanged
behavior), threaded through SessionConfigComposer -> client.err.log
beside status.jsonl -> LauncherExecutableSet -> LauncherOrchestrator.

Tests: GameWindowCrashStatusTests (source-shape, matching the existing
GameWindow test pattern — the class cannot be constructed without a live
GPU/window), BoundedProcessOutputCaptureTests (10 unit tests), and three
new LauncherProcessSupervisorTests spawning real child processes through
both capture code paths.

Launcher.Core.Tests: 337/0 (was 324/0). Launcher.Tests: 67/0 (unchanged).
Full solution build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 11:44:46 +02:00
parent 1d9de5e095
commit 691b925952
13 changed files with 1150 additions and 38 deletions

View file

@ -5,12 +5,17 @@ using AcDream.Platform;
namespace AcDream.Launcher.Core.Launching;
/// <summary>The composed session-config document plus the two per-launch
/// paths derived from the session id, per Campaign LA spec §6.</summary>
/// <summary>The composed session-config document plus the per-launch
/// paths derived from the session id, per Campaign LA spec §6.
/// <see cref="StderrLogPath"/> is launcher-internal (fix #406 sibling
/// gap) — it never appears in the written <c>session.json</c>, only in
/// the <see cref="Launching.LauncherProcessSpec"/> the launcher spawns the
/// child with.</summary>
public sealed record ComposedSessionConfig(
string SessionId,
string ConfigFilePath,
string StatusFilePath,
string StderrLogPath,
SessionConfigDocument Document);
/// <summary>
@ -110,7 +115,8 @@ public static class SessionConfigComposer
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
(string configFilePath, string statusFilePath) = BuildSessionPaths(paths, sessionId);
(string configFilePath, string statusFilePath, string stderrLogPath) =
BuildSessionPaths(paths, sessionId);
SessionCharacterSelector? selector = character.LaunchMode == LaunchMode.GuiSelect
? null
@ -159,6 +165,7 @@ public static class SessionConfigComposer
sessionId,
configFilePath,
statusFilePath,
stderrLogPath,
document);
}
@ -184,7 +191,8 @@ public static class SessionConfigComposer
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
(string configFilePath, string statusFilePath) = BuildSessionPaths(paths, sessionId);
(string configFilePath, string statusFilePath, string stderrLogPath) =
BuildSessionPaths(paths, sessionId);
var descriptor = new SessionDescriptor
{
@ -222,6 +230,7 @@ public static class SessionConfigComposer
sessionId,
configFilePath,
statusFilePath,
stderrLogPath,
document);
}
@ -282,9 +291,10 @@ public static class SessionConfigComposer
public static string Serialize(SessionConfigDocument document) =>
JsonSerializer.Serialize(document, SerializerOptions);
private static (string ConfigFilePath, string StatusFilePath) BuildSessionPaths(
ApplicationPathSet paths,
string sessionId)
private static (string ConfigFilePath, string StatusFilePath, string StderrLogPath)
BuildSessionPaths(
ApplicationPathSet paths,
string sessionId)
{
string sessionDirectory = Path.Combine(
paths.CacheDirectory,
@ -294,7 +304,10 @@ public static class SessionConfigComposer
return (
Path.Combine(sessionDirectory, "session.json"),
Path.Combine(sessionDirectory, "status.jsonl"));
Path.Combine(sessionDirectory, "status.jsonl"),
// fix #406 sibling gap: lives beside status.jsonl in the same
// per-session directory.
Path.Combine(sessionDirectory, "client.err.log"));
}
private static SessionCharacterSelector BuildSelector(CharacterProfile character)