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>
139 lines
5.1 KiB
C#
139 lines
5.1 KiB
C#
namespace AcDream.App.Tests.Rendering;
|
|
|
|
/// <summary>
|
|
/// Fix #406: before this change, <c>GameWindow.CompleteShutdown</c> wrote
|
|
/// a hardcoded <c>exited{code:0,reason:"graceful"}</c> status event
|
|
/// whenever the resource-shutdown transaction converged — even when
|
|
/// <c>Dispose()</c> (and therefore <c>CompleteShutdown</c>) ran mid-unwind
|
|
/// of an exception that escaped <c>Run()</c>'s Silk.NET frame loop and was
|
|
/// about to crash the process via the CLR's unhandled-exception path.
|
|
/// <c>GameWindow</c> cannot be constructed without a live GPU/window (see
|
|
/// the established pattern in <c>GameWindowSlice8BoundaryTests</c>), so
|
|
/// this pins the fix as a source-shape test exactly like that file does
|
|
/// for the surrounding shutdown machinery.
|
|
/// </summary>
|
|
public sealed class GameWindowCrashStatusTests
|
|
{
|
|
[Fact]
|
|
public void Run_LatchesRunFailureBeforeRethrowingFromTheFrameLoopCatch()
|
|
{
|
|
string body = MethodBody(
|
|
"public void Run()",
|
|
"void IGameWindowPlatformPublication<GameWindowGraphics, IInputContext>.PublishGraphics(");
|
|
string tryBlock = Slice(body, "try\n {\n _window.Run();", "}\n }");
|
|
|
|
AssertAppearsInOrder(
|
|
tryBlock,
|
|
"_window.Run();",
|
|
"catch (Exception failure)",
|
|
"_constructionCleanup.RetainFrom(failure);",
|
|
// The latch MUST happen before the rethrow: Dispose() (and
|
|
// therefore CompleteShutdown/ReportExited) can run mid-unwind
|
|
// of this exact exception, via Program.cs's
|
|
// `using var window = ...`.
|
|
"_runFailure = failure;",
|
|
"throw;");
|
|
}
|
|
|
|
[Fact]
|
|
public void ReportExited_ChecksRunFailureBeforeEitherGracefulOrShutdownIncompletePaths()
|
|
{
|
|
string source = GameWindowSource();
|
|
string reportExited = Slice(
|
|
source,
|
|
"private void ReportExited(GameWindowLifetimeReport report)",
|
|
"\n }\n");
|
|
|
|
Assert.Contains(
|
|
"string sessionId = _options.SessionId ?? \"app\";",
|
|
reportExited,
|
|
StringComparison.Ordinal);
|
|
AssertAppearsInOrder(
|
|
reportExited,
|
|
"if (_runFailure is not null)",
|
|
"_statusWriter.Exited(sessionId, 1, \"crashed\");",
|
|
"return;",
|
|
"if (report.Status == GameWindowLifetimeStatus.Complete)",
|
|
"_statusWriter.Exited(sessionId, 0, \"graceful\");",
|
|
"_statusWriter.Exited(sessionId, 1, \"shutdown-incomplete\");");
|
|
|
|
// Every terminal-status write in CompleteShutdown funnels through
|
|
// this ONE method — a second, uncoordinated call site would be
|
|
// exactly how the pre-fix bug reappears.
|
|
Assert.Equal(
|
|
2,
|
|
CountOccurrences(source, "ReportExited(report)"));
|
|
Assert.DoesNotContain(
|
|
"_statusWriter.Exited(_options.SessionId ?? \"app\", 0, \"graceful\")",
|
|
source,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void RunFailureFieldExistsAndDefaultsToNull()
|
|
{
|
|
string source = GameWindowSource();
|
|
|
|
Assert.Contains(
|
|
"private Exception? _runFailure;",
|
|
source,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
private static string MethodBody(string start, string end) =>
|
|
Slice(GameWindowSource(), start, end);
|
|
|
|
private static string Slice(string source, string start, string end)
|
|
{
|
|
int first = source.IndexOf(start, StringComparison.Ordinal);
|
|
int last = source.IndexOf(end, first + 1, StringComparison.Ordinal);
|
|
Assert.True(first >= 0, $"Missing source boundary: {start}");
|
|
Assert.True(last > first, $"Missing source boundary: {end}");
|
|
return source[first..last];
|
|
}
|
|
|
|
private static int CountOccurrences(string source, string value)
|
|
{
|
|
int count = 0;
|
|
int cursor = 0;
|
|
while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0)
|
|
{
|
|
count++;
|
|
cursor += value.Length;
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
private static void AssertAppearsInOrder(string source, params string[] fragments)
|
|
{
|
|
int cursor = -1;
|
|
foreach (string fragment in fragments)
|
|
{
|
|
int next = source.IndexOf(fragment, cursor + 1, StringComparison.Ordinal);
|
|
Assert.True(next >= 0, $"Missing expected source fragment: {fragment}");
|
|
Assert.True(next > cursor, $"Out-of-order source fragment: {fragment}");
|
|
cursor = next;
|
|
}
|
|
}
|
|
|
|
private static string GameWindowSource() => File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"GameWindow.cs")).Replace("\r\n", "\n", StringComparison.Ordinal);
|
|
|
|
private static string FindRepoRoot()
|
|
{
|
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
|
return directory.FullName;
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
|
}
|
|
}
|