Merge campaign-launcher-406: fix #406 — truthful client exit self-report + bounded stderr capture

The crashed-client 'graceful' status line was the CLIENT's own Dispose-path
self-report, not the launcher's observation; Run() now latches the escaping
failure and the shutdown report writes reason:'crashed'. Sessions also gain
a bounded client.err.log beside status.jsonl on both spawn paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 12:36:00 +02:00
commit 0b05b58514
13 changed files with 1150 additions and 38 deletions

View file

@ -137,6 +137,14 @@ public sealed class GameWindow :
_constructionCleanup = new();
private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment;
private readonly GameWindowLifetime _lifetime = new();
// fix #406: set by Run()'s own catch the instant an exception escapes
// the Silk.NET frame loop, BEFORE it is rethrown and unwinds through
// Program.cs's `using var window = ...` (which calls Dispose() —
// therefore CompleteShutdown() — while that exception is still in
// flight). CompleteShutdown consults this so a crash is never reported
// as the hardcoded "exited{code:0,reason:graceful}" the resource
// teardown transaction's own convergence would otherwise imply.
private Exception? _runFailure;
private readonly DisplayFramePacingController _displayFramePacing;
private readonly RuntimeSettingsController _runtimeSettings;
@ -820,6 +828,10 @@ public sealed class GameWindow :
catch (Exception failure)
{
_constructionCleanup.RetainFrom(failure);
// fix #406: latch BEFORE rethrowing — Dispose() (and therefore
// CompleteShutdown) can run mid-unwind of this exact exception,
// via Program.cs's `using var window = ...`.
_runFailure = failure;
throw;
}
}
@ -1698,7 +1710,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, "graceful");
ReportExited(report);
return;
}
@ -1715,12 +1727,41 @@ public sealed class GameWindow :
Console.Error.WriteLine($"[shutdown] {report.Error}");
if (releaseNativeWindow)
ReportExited(report);
}
/// <summary>
/// Writes the ONE terminal "exited" status event for this session
/// (fix #406). A resource-shutdown transaction can converge cleanly
/// (<paramref name="report"/>'s own <see cref="GameWindowLifetimeReport.Status"/>
/// says nothing about this) even though this <see cref="Dispose"/> call
/// is running mid-unwind of an exception that escaped
/// <see cref="Run"/>'s frame loop and is about to terminate the process
/// via the CLR's unhandled-exception path — <see cref="_runFailure"/>
/// is the one signal that actually distinguishes those two cases.
/// Before this fix every such crash wrote the exact same
/// "exited{code:0,reason:graceful}" as a real graceful shutdown,
/// sending any launcher-side diagnosis in the wrong direction (#406).
/// </summary>
private void ReportExited(GameWindowLifetimeReport report)
{
string sessionId = _options.SessionId ?? "app";
if (_runFailure is not null)
{
_statusWriter.Exited(
_options.SessionId ?? "app",
1,
"shutdown-incomplete");
// The real OS-level exit code (e.g. 0xE0434352 on Windows for
// an unhandled .NET exception) is produced by the runtime AFTER
// this method returns and the exception keeps propagating — it
// cannot be predicted from here. "crashed" is the truthful,
// platform-independent classification; the launcher's own
// process supervisor observes the real OS exit code separately.
_statusWriter.Exited(sessionId, 1, "crashed");
return;
}
if (report.Status == GameWindowLifetimeStatus.Complete)
_statusWriter.Exited(sessionId, 0, "graceful");
else
_statusWriter.Exited(sessionId, 1, "shutdown-incomplete");
}
private GameWindowShutdownRoots CaptureShutdownRoots() => new(