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

@ -0,0 +1,139 @@
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.");
}
}

View file

@ -1,5 +1,34 @@
using System.Text.Json;
// fix #406 sibling gap: a "write-stderr" mode alongside the existing
// "wait-for-break" mode so the launcher's stderr-capture tests can drive a
// real child process (both WindowsSystemChildProcess's native pipe path
// and SystemChildProcess's ProcessStartInfo.RedirectStandardError path)
// without a second fixture project. Usage:
// write-stderr <exitCode> <lineCount> <lineText>
// Writes <lineText> followed by its 0-based index, one per line, to
// stderr <lineCount> times (flushing every line so a launcher-side pump
// observes them incrementally rather than all at once on process exit),
// then returns <exitCode>.
if (args.Length >= 1 && args[0] == "write-stderr")
{
if (args.Length < 4
|| !int.TryParse(args[1], out int exitCode)
|| !int.TryParse(args[2], out int lineCount))
{
return 64;
}
string lineText = args[3];
for (int index = 0; index < lineCount; index++)
{
Console.Error.WriteLine($"{lineText}{index}");
Console.Error.Flush();
}
return exitCode;
}
if (args.Length < 4
|| args[0] != "wait-for-break"
|| string.IsNullOrWhiteSpace(args[1])

View file

@ -0,0 +1,240 @@
using System.Text;
using AcDream.Launcher.Core.Launching;
namespace AcDream.Launcher.Core.Tests.Launching;
/// <summary>Fix #406 sibling gap: the launcher previously discarded a
/// supervised child's stderr entirely, so diagnosing a crash (including
/// exactly the #406 crash) required re-running the identical binary by
/// hand. These tests cover <see cref="BoundedProcessOutputCapture"/> in
/// isolation — the real-child-process end-to-end capture tests live in
/// <c>LauncherProcessSupervisorTests</c> alongside the existing real-process
/// coverage.</summary>
public sealed class BoundedProcessOutputCaptureTests
{
[Fact]
public void AppendLineWritesEachLineWithATrailingNewline()
{
string path = TempPath();
try
{
using var capture = new BoundedProcessOutputCapture(path);
capture.AppendLine("first");
capture.AppendLine("second");
capture.Dispose();
Assert.Equal("first\nsecond\n", File.ReadAllText(path));
}
finally
{
TryDelete(path);
}
}
[Fact]
public void ANullLineFromTheEndOfStreamSentinelIsANoOp()
{
string path = TempPath();
try
{
using var capture = new BoundedProcessOutputCapture(path);
capture.AppendLine("kept");
capture.AppendLine(null);
capture.Dispose();
Assert.Equal("kept\n", File.ReadAllText(path));
}
finally
{
TryDelete(path);
}
}
[Fact]
public void WritesBeyondTheCapAreDroppedAndAOneTimeTruncationMarkerIsAppended()
{
string path = TempPath();
try
{
using var capture = new BoundedProcessOutputCapture(path, maxBytes: 16);
capture.AppendLine("0123456789"); // 11 bytes incl. newline
capture.AppendLine("this line is dropped entirely");
capture.AppendLine("so is this one");
Assert.True(capture.IsDone);
string written = File.ReadAllText(path);
Assert.StartsWith("0123456789\n", written, StringComparison.Ordinal);
Assert.Contains("truncated at 16 bytes", written, StringComparison.Ordinal);
// The cap is a hard ceiling: nothing past it EVER lands on disk,
// even the marker's own text does not push the file arbitrarily
// far past the configured bound.
Assert.True(
written.Length < 200,
$"expected a small bounded file, got {written.Length} bytes");
}
finally
{
TryDelete(path);
}
}
[Fact]
public void ALogSpammingChildCannotGrowTheFileUnboundedly()
{
string path = TempPath();
try
{
using var capture = new BoundedProcessOutputCapture(
path,
maxBytes: BoundedProcessOutputCapture.DefaultMaxBytes);
// Far more than the 2 MiB default cap.
string spamLine = new('x', 4096);
for (int i = 0; i < 4096; i++)
{
capture.AppendLine(spamLine);
if (capture.IsDone)
{
break;
}
}
Assert.True(capture.IsDone);
long fileLength = new FileInfo(path).Length;
Assert.True(
fileLength < BoundedProcessOutputCapture.DefaultMaxBytes + 256,
$"expected the file to stay near the {BoundedProcessOutputCapture.DefaultMaxBytes}-byte "
+ $"cap, got {fileLength} bytes");
}
finally
{
TryDelete(path);
}
}
[Fact]
public void AppendCreatesTheSessionDirectoryOnFirstWrite()
{
string directory = Path.Combine(
Path.GetTempPath(),
"acdream-406-capture-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "client.err.log");
Assert.False(Directory.Exists(directory));
try
{
using var capture = new BoundedProcessOutputCapture(path);
capture.AppendLine("hello");
capture.Dispose();
Assert.True(File.Exists(path));
}
finally
{
try
{
Directory.Delete(directory, recursive: true);
}
catch (IOException)
{
}
}
}
[Fact]
public void RawByteAppendsAreConcatenatedWithoutAnImpliedLineBoundary()
{
string path = TempPath();
try
{
using var capture = new BoundedProcessOutputCapture(path);
capture.Append(Encoding.UTF8.GetBytes("abc"));
capture.Append(Encoding.UTF8.GetBytes("def"));
capture.Dispose();
Assert.Equal("abcdef", File.ReadAllText(path));
}
finally
{
TryDelete(path);
}
}
[Fact]
public void EmptyAppendsAreNoOps()
{
string path = TempPath();
try
{
using var capture = new BoundedProcessOutputCapture(path);
capture.Append(ReadOnlySpan<byte>.Empty);
capture.AppendLine(string.Empty);
capture.Dispose();
// An empty string line still gets its trailing newline —
// only a genuinely zero-length byte span (or a null line) is
// a true no-op.
Assert.Equal("\n", File.ReadAllText(path));
}
finally
{
TryDelete(path);
}
}
[Fact]
public void AppendAfterDisposeIsASilentNoOp()
{
string path = TempPath();
try
{
var capture = new BoundedProcessOutputCapture(path);
capture.AppendLine("before");
capture.Dispose();
capture.AppendLine("after — must not throw or reopen the file");
Assert.Equal("before\n", File.ReadAllText(path));
}
finally
{
TryDelete(path);
}
}
[Fact]
public void ConstructorRejectsANonPositiveMaxBytes()
{
Assert.Throws<ArgumentOutOfRangeException>(
() => new BoundedProcessOutputCapture(TempPath(), maxBytes: 0));
Assert.Throws<ArgumentOutOfRangeException>(
() => new BoundedProcessOutputCapture(TempPath(), maxBytes: -1));
}
[Fact]
public void ConstructorRejectsANullOrBlankPath()
{
Assert.Throws<ArgumentException>(() => new BoundedProcessOutputCapture(""));
Assert.Throws<ArgumentException>(() => new BoundedProcessOutputCapture(" "));
}
private static string TempPath() => Path.Combine(
Path.GetTempPath(),
"acdream-406-capture-" + Guid.NewGuid().ToString("N") + ".log");
private static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch (IOException)
{
}
}
}

View file

@ -543,6 +543,182 @@ public sealed class LauncherProcessSupervisorTests
Assert.Equal(0, supervisor.ExitCode);
}
[Fact]
public async Task RealChildStderrIsCapturedForTheProcessStartInfoPath()
{
// Fix #406 sibling gap: SystemChildProcess is used on Linux for
// EVERY child, and on Windows for graphical/non-console children —
// exactly #406's own App/GUI crash scenario
// (SupportsConsoleGracefulStop: false is the graphical shape;
// see LauncherExecutableSet.CreatePlaySpec). This proves the real
// child's stderr actually lands in the configured bounded file
// instead of being discarded, and that the real nonzero exit code
// is still observed independently of the capture.
string root = Path.Combine(
Path.GetTempPath(),
"acdream-406-stderr-psi",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
string stderrPath = Path.Combine(root, "client.err.log");
try
{
using var supervisor = new LauncherProcessSupervisor();
var exited = new ManualResetEventSlim(false);
supervisor.StateChanged += (_, s) =>
{
if (s == LauncherSessionState.Exited)
exited.Set();
};
supervisor.Start(
new LauncherProcessSpec(
FindDotnetExecutable(),
[GetConsoleFixturePath(), "write-stderr", "7", "3", "line-"],
SupportsConsoleGracefulStop: false,
StderrLogPath: stderrPath),
password: null);
Assert.True(
exited.Wait(TimeSpan.FromSeconds(30)),
"the write-stderr fixture did not exit within 30s");
Assert.Equal(7, supervisor.ExitCode);
string captured = await ReadFileEventuallyContainingAsync(
stderrPath, "line-2", TimeSpan.FromSeconds(5));
Assert.Contains("line-0", captured, StringComparison.Ordinal);
Assert.Contains("line-1", captured, StringComparison.Ordinal);
Assert.Contains("line-2", captured, StringComparison.Ordinal);
}
finally
{
try
{
Directory.Delete(root, recursive: true);
}
catch (IOException)
{
}
}
}
[Fact]
public async Task RealChildStderrIsCapturedForTheWindowsNativeConsolePath()
{
// Fix #406 sibling gap: on Windows, console-capable children
// (Headless) spawn through WindowsSystemChildProcess's native
// CreateProcessW path, a completely separate code path from
// SystemChildProcess above — WindowsProcessNative.StartCore
// creates a real pipe for stderr instead of the usual
// duplicate-or-NUL handle, and WindowsSystemChildProcess pumps it
// on a background thread. This proves that path end to end too.
if (!OperatingSystem.IsWindows())
{
return;
}
string root = Path.Combine(
Path.GetTempPath(),
"acdream-406-stderr-native",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
string stderrPath = Path.Combine(root, "client.err.log");
try
{
using var supervisor = new LauncherProcessSupervisor();
var exited = new ManualResetEventSlim(false);
supervisor.StateChanged += (_, s) =>
{
if (s == LauncherSessionState.Exited)
exited.Set();
};
supervisor.Start(
new LauncherProcessSpec(
FindDotnetExecutable(),
[GetConsoleFixturePath(), "write-stderr", "9", "3", "native-line-"],
StderrLogPath: stderrPath),
password: null);
Assert.True(
exited.Wait(TimeSpan.FromSeconds(30)),
"the write-stderr fixture did not exit within 30s");
Assert.Equal(9, supervisor.ExitCode);
string captured = await ReadFileEventuallyContainingAsync(
stderrPath, "native-line-2", TimeSpan.FromSeconds(5));
Assert.Contains("native-line-0", captured, StringComparison.Ordinal);
Assert.Contains("native-line-1", captured, StringComparison.Ordinal);
Assert.Contains("native-line-2", captured, StringComparison.Ordinal);
}
finally
{
try
{
Directory.Delete(root, recursive: true);
}
catch (IOException)
{
}
}
}
[Fact]
public void ANullStderrLogPathBehavesExactlyAsBeforeForBothChildProcessKinds()
{
// The additive-diagnostics contract: a spec with no StderrLogPath
// must not change behavior at all (fix #406 sibling gap review
// guard against a regression that always redirects stderr).
string dotnet = FindDotnetExecutable();
using var supervisor = new LauncherProcessSupervisor();
var exited = new ManualResetEventSlim(false);
supervisor.StateChanged += (_, s) =>
{
if (s == LauncherSessionState.Exited)
exited.Set();
};
supervisor.Start(new LauncherProcessSpec(dotnet, ["--version"]), null);
Assert.True(exited.Wait(TimeSpan.FromSeconds(30)));
Assert.Equal(0, supervisor.ExitCode);
}
private static async Task<string> ReadFileEventuallyContainingAsync(
string path,
string expectedFragment,
TimeSpan timeout)
{
DateTime deadline = DateTime.UtcNow + timeout;
string last = string.Empty;
while (DateTime.UtcNow < deadline)
{
if (File.Exists(path))
{
try
{
last = await File.ReadAllTextAsync(path);
if (last.Contains(expectedFragment, StringComparison.Ordinal))
{
return last;
}
}
catch (IOException)
{
// The pump/writer may hold the file open for a
// moment — retry within the deadline.
}
}
await Task.Delay(20);
}
throw new TimeoutException(
$"'{path}' never contained '{expectedFragment}' within {timeout}. "
+ $"Last observed content: {last}");
}
private static LauncherProcessSpec Spec() =>
new("fake-host", ["--session-config", "session.json"]);