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:
parent
1d9de5e095
commit
691b925952
13 changed files with 1150 additions and 38 deletions
139
tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs
Normal file
139
tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue