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:
commit
0b05b58514
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