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(

View file

@ -0,0 +1,238 @@
using System.Text;
namespace AcDream.Launcher.Core.Launching;
/// <summary>
/// Captures a supervised child's stderr into a per-session file, bounded
/// so a log-spamming (or endlessly crash-looping) child can never fill the
/// disk (fix #406 sibling gap). Before this class existed the launcher
/// discarded a child's stdout/stderr entirely — including the unhandled-
/// exception stack trace a crash writes there — so diagnosing exactly the
/// #406 crash required re-running the identical binary + session config
/// from a console by hand. This is purely additive diagnostics: it does
/// not touch the pinned <c>status.jsonl</c> event vocabulary (Campaign LA
/// plan §LA1) at all.
///
/// <para>
/// Every write opens the file fresh (<see cref="FileMode.Append"/>),
/// writes its chunk, flushes, and closes — mirroring
/// <see cref="AcDream.Runtime.Session.SessionStatusWriter"/>'s "no long-
/// lived file handle" posture exactly, and for the SAME reason: a
/// long-lived write handle only opened with <see cref="FileShare.Read"/>
/// is NOT actually concurrently readable in practice — Windows' sharing
/// check is bidirectional, and a plain <c>File.ReadAllText</c>-style
/// reader (which itself only requests <see cref="FileShare.Read"/>, not
/// <see cref="FileShare.ReadWrite"/>) fails with a sharing violation
/// against ANY still-open handle that holds write access, regardless of
/// what share flags that writer declared. Opening fresh per write avoids
/// the problem entirely: there is never a handle open except for the
/// duration of one small, synchronous write.
/// </para>
///
/// <para>
/// Every write is defensively guarded the same way
/// <see cref="AcDream.Runtime.Session.SessionStatusWriter"/> guards its own
/// I/O: a recoverable failure latches this sink into a permanent no-op
/// rather than throwing back into the caller's read-and-forward loop. The
/// launcher's job is to supervise the child, not to go down because a
/// local diagnostics file could not be written.
/// </para>
///
/// <para>
/// Callers are responsible for continuing to drain the child's stderr
/// stream/pipe even after this sink stops accepting bytes (cap reached or
/// latched off) — this class only bounds what lands on disk, never how
/// much the caller may read. A caller that stopped draining on a full
/// sink could leave the child blocked writing to a full OS pipe buffer.
/// </para>
/// </summary>
public sealed class BoundedProcessOutputCapture : IDisposable
{
/// <summary>2 MiB is generous for the lifecycle/shutdown diagnostics
/// and a crash stack trace this exists to capture, while still being a
/// firm, small bound against a pathological child that spams stderr
/// for an entire long-running headless-bot session.</summary>
public const long DefaultMaxBytes = 2 * 1024 * 1024;
private static readonly byte[] Newline = "\n"u8.ToArray();
private readonly string _path;
private readonly long _maxBytes;
private readonly object _gate = new();
private bool _directoryEnsured;
private long _written;
private bool _capped;
private bool _latchedOff;
private bool _disposed;
public BoundedProcessOutputCapture(string path, long maxBytes = DefaultMaxBytes)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (maxBytes <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(maxBytes),
"The bounded capture size must be positive.");
}
_path = Path.GetFullPath(path);
_maxBytes = maxBytes;
}
/// <summary>True once no further byte will ever be written — either
/// the size cap was reached (a truncation marker was appended) or a
/// local I/O failure latched this sink off. Exposed for tests; a
/// caller never needs to check this before calling
/// <see cref="AppendLine"/> — it is always safe to call.</summary>
public bool IsDone
{
get
{
lock (_gate)
{
return _capped || _latchedOff || _disposed;
}
}
}
/// <summary>Appends one line of already-decoded text (e.g. one
/// <c>Process.ErrorDataReceived</c> line) followed by a newline. Never
/// throws. A <see langword="null"/> line (the sentinel .NET's
/// <c>ErrorDataReceived</c> raises once when the stream closes) is a
/// silent no-op.</summary>
public void AppendLine(string? line)
{
if (line is null)
{
return;
}
lock (_gate)
{
AppendLocked(Encoding.UTF8.GetBytes(line));
AppendLocked(Newline);
}
}
/// <summary>Appends a raw decoded chunk (no implied line boundary).
/// Never throws.</summary>
public void Append(ReadOnlySpan<byte> data)
{
if (data.IsEmpty)
{
return;
}
lock (_gate)
{
AppendLocked(data);
}
}
private void AppendLocked(ReadOnlySpan<byte> data)
{
if (data.IsEmpty || _disposed || _latchedOff || _capped)
{
return;
}
try
{
long remaining = _maxBytes - _written;
if (remaining <= 0)
{
CapLocked();
return;
}
int toWrite = data.Length > remaining
? checked((int)remaining)
: data.Length;
WriteChunkLocked(data[..toWrite]);
_written += toWrite;
if (toWrite < data.Length)
{
CapLocked();
}
}
catch (Exception error) when (IsRecoverableIoFailure(error))
{
_latchedOff = true;
}
}
/// <summary>Opens the file fresh, writes one chunk, flushes, and
/// closes — see the class doc for why this never keeps a long-lived
/// handle. Exceptions propagate to the caller's own guard.</summary>
private void WriteChunkLocked(ReadOnlySpan<byte> chunk)
{
EnsureDirectoryLocked();
using FileStream stream = new(
_path,
FileMode.Append,
FileAccess.Write,
FileShare.Read);
stream.Write(chunk);
stream.Flush();
}
private void EnsureDirectoryLocked()
{
if (_directoryEnsured)
{
return;
}
string? directory = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
_directoryEnsured = true;
}
/// <summary>Writes the one-time truncation marker — every later
/// <see cref="Append"/>/<see cref="AppendLine"/> becomes a cheap
/// no-op via <see cref="_capped"/>.</summary>
private void CapLocked()
{
if (_capped)
{
return;
}
_capped = true;
try
{
byte[] marker = Encoding.UTF8.GetBytes(
$"\n[acdream-launcher] client.err.log truncated at {_maxBytes} bytes\n");
WriteChunkLocked(marker);
}
catch (Exception error) when (IsRecoverableIoFailure(error))
{
// The marker itself is best-effort — the cap already took
// effect via _capped regardless of whether it could be written.
}
}
private static bool IsRecoverableIoFailure(Exception error) =>
error is IOException
or UnauthorizedAccessException
or NotSupportedException
or System.Security.SecurityException
or DirectoryNotFoundException;
/// <summary>No open handle to release — see the class doc. Marks this
/// sink permanently done so any late-arriving chunk from a caller's
/// still-draining pump is a silent no-op instead of reopening the
/// file after the caller considers capture finished.</summary>
public void Dispose()
{
lock (_gate)
{
_disposed = true;
}
}
}

View file

@ -88,7 +88,9 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess
private readonly Process _process;
private readonly bool _supportsConsoleGracefulStop;
private readonly BoundedProcessOutputCapture? _stderrCapture;
private bool _raisingEnabled;
private bool _errorReadingEnabled;
internal SystemChildProcess(LauncherProcessSpec spec)
{
@ -102,6 +104,17 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess
UseShellExecute = false,
};
// fix #406 sibling gap: capture stderr (crash stack traces land
// there) into a bounded per-session file instead of discarding it.
// Purely additive — RedirectStandardOutput/CreateNoWindow are left
// untouched, and a spec with no StderrLogPath behaves exactly as
// before.
if (!string.IsNullOrWhiteSpace(spec.StderrLogPath))
{
startInfo.RedirectStandardError = true;
_stderrCapture = new BoundedProcessOutputCapture(spec.StderrLogPath);
}
foreach (string argument in spec.Arguments)
{
startInfo.ArgumentList.Add(argument);
@ -128,7 +141,17 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess
_process.EnableRaisingEvents = true;
_process.Exited += OnExited;
_raisingEnabled = true;
if (_stderrCapture is not null)
{
_process.ErrorDataReceived += OnErrorDataReceived;
_errorReadingEnabled = true;
}
_process.Start();
if (_errorReadingEnabled)
{
_process.BeginErrorReadLine();
}
}
public bool TryRequestGracefulStop()
@ -169,9 +192,18 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess
_process.Exited -= OnExited;
}
if (_errorReadingEnabled)
{
_process.ErrorDataReceived -= OnErrorDataReceived;
}
_process.Dispose();
_stderrCapture?.Dispose();
}
private void OnExited(object? sender, EventArgs e) =>
Exited?.Invoke(this, EventArgs.Empty);
private void OnErrorDataReceived(object? sender, DataReceivedEventArgs e) =>
_stderrCapture?.AppendLine(e.Data);
}

View file

@ -11,9 +11,14 @@ namespace AcDream.Launcher.Core.Launching;
/// <paramref name="SupportsConsoleGracefulStop"/> so Windows starts them
/// as isolated process-group leaders for targeted CTRL_BREAK_EVENT and
/// Linux sends SIGINT; graphical specs leave it false and use WM_CLOSE.
/// <paramref name="StderrLogPath"/> is an optional, purely-additive
/// diagnostics sink (fix #406 sibling gap): when set, the launcher
/// captures the child's stderr into a bounded file at that path instead
/// of discarding it; when null, behavior is exactly as before.
/// </summary>
public sealed record LauncherProcessSpec(
string ExecutablePath,
IReadOnlyList<string> Arguments,
string? WorkingDirectory = null,
bool SupportsConsoleGracefulStop = true);
bool SupportsConsoleGracefulStop = true,
string? StderrLogPath = null);

View file

@ -5,12 +5,17 @@ using AcDream.Platform;
namespace AcDream.Launcher.Core.Launching;
/// <summary>The composed session-config document plus the two per-launch
/// paths derived from the session id, per Campaign LA spec §6.</summary>
/// <summary>The composed session-config document plus the per-launch
/// paths derived from the session id, per Campaign LA spec §6.
/// <see cref="StderrLogPath"/> is launcher-internal (fix #406 sibling
/// gap) — it never appears in the written <c>session.json</c>, only in
/// the <see cref="Launching.LauncherProcessSpec"/> the launcher spawns the
/// child with.</summary>
public sealed record ComposedSessionConfig(
string SessionId,
string ConfigFilePath,
string StatusFilePath,
string StderrLogPath,
SessionConfigDocument Document);
/// <summary>
@ -110,7 +115,8 @@ public static class SessionConfigComposer
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
(string configFilePath, string statusFilePath) = BuildSessionPaths(paths, sessionId);
(string configFilePath, string statusFilePath, string stderrLogPath) =
BuildSessionPaths(paths, sessionId);
SessionCharacterSelector? selector = character.LaunchMode == LaunchMode.GuiSelect
? null
@ -159,6 +165,7 @@ public static class SessionConfigComposer
sessionId,
configFilePath,
statusFilePath,
stderrLogPath,
document);
}
@ -184,7 +191,8 @@ public static class SessionConfigComposer
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
(string configFilePath, string statusFilePath) = BuildSessionPaths(paths, sessionId);
(string configFilePath, string statusFilePath, string stderrLogPath) =
BuildSessionPaths(paths, sessionId);
var descriptor = new SessionDescriptor
{
@ -222,6 +230,7 @@ public static class SessionConfigComposer
sessionId,
configFilePath,
statusFilePath,
stderrLogPath,
document);
}
@ -282,9 +291,10 @@ public static class SessionConfigComposer
public static string Serialize(SessionConfigDocument document) =>
JsonSerializer.Serialize(document, SerializerOptions);
private static (string ConfigFilePath, string StatusFilePath) BuildSessionPaths(
ApplicationPathSet paths,
string sessionId)
private static (string ConfigFilePath, string StatusFilePath, string StderrLogPath)
BuildSessionPaths(
ApplicationPathSet paths,
string sessionId)
{
string sessionDirectory = Path.Combine(
paths.CacheDirectory,
@ -294,7 +304,10 @@ public static class SessionConfigComposer
return (
Path.Combine(sessionDirectory, "session.json"),
Path.Combine(sessionDirectory, "status.jsonl"));
Path.Combine(sessionDirectory, "status.jsonl"),
// fix #406 sibling gap: lives beside status.jsonl in the same
// per-session directory.
Path.Combine(sessionDirectory, "client.err.log"));
}
private static SessionCharacterSelector BuildSelector(CharacterProfile character)

View file

@ -20,6 +20,10 @@ internal sealed class WindowsSystemChildProcess : ILauncherChildProcess
private TextWriter? _standardInput;
private int _processGroupId;
private bool _raisingEnabled;
// fix #406 sibling gap: null unless _spec.StderrLogPath was set.
private BoundedProcessOutputCapture? _stderrCapture;
private FileStream? _stderrReadStream;
private Thread? _stderrPumpThread;
internal WindowsSystemChildProcess(
LauncherProcessSpec spec,
@ -54,6 +58,30 @@ internal sealed class WindowsSystemChildProcess : ILauncherChildProcess
_raisingEnabled = true;
_standardInput = started.TakeStandardInput();
_processGroupId = started.ProcessId;
if (!string.IsNullOrWhiteSpace(_spec.StderrLogPath))
{
// fix #406 sibling gap: drain the real stderr pipe
// WindowsProcessNative.StartCore created for this spec into
// a bounded file, BEFORE resuming the suspended child below
// — the pump is already running by the time the child can
// write anything.
SafeFileHandle stderrRead = started.TakeStandardErrorRead()
?? throw new InvalidOperationException(
"The launcher child stderr pipe was not created.");
_stderrCapture = new BoundedProcessOutputCapture(_spec.StderrLogPath);
_stderrReadStream = new FileStream(
stderrRead,
FileAccess.Read,
4096,
isAsync: false);
_stderrPumpThread = new Thread(PumpStderr)
{
IsBackground = true,
Name = "acdream-launcher-stderr-pump",
};
_stderrPumpThread.Start();
}
started.Resume();
}
catch
@ -61,6 +89,10 @@ internal sealed class WindowsSystemChildProcess : ILauncherChildProcess
started.Terminate();
_standardInput?.Dispose();
_standardInput = null;
_stderrReadStream?.Dispose();
_stderrReadStream = null;
_stderrCapture?.Dispose();
_stderrCapture = null;
if (_process is not null)
{
if (_raisingEnabled)
@ -80,6 +112,43 @@ internal sealed class WindowsSystemChildProcess : ILauncherChildProcess
}
}
/// <summary>
/// Runs on a dedicated background thread for the lifetime of the
/// capture (fix #406 sibling gap): continuously drains the child's
/// stderr pipe into <see cref="_stderrCapture"/> so the child's writes
/// never block on a full OS pipe buffer, even after the capture sink
/// itself has stopped accepting bytes (size cap reached, or a local
/// I/O failure latched it off — <see cref="BoundedProcessOutputCapture"/>
/// never throws). Returns cleanly once the pipe's write end closes
/// (the child exited) or <see cref="Dispose"/> closes the read end.
/// </summary>
private void PumpStderr()
{
FileStream? stream = _stderrReadStream;
BoundedProcessOutputCapture? capture = _stderrCapture;
if (stream is null || capture is null)
{
return;
}
byte[] buffer = new byte[4096];
try
{
int read;
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
{
capture.Append(buffer.AsSpan(0, read));
}
}
catch (Exception error)
when (error is IOException or ObjectDisposedException)
{
// The pipe's write end closed (the child exited) or Dispose()
// released the read end concurrently — either way, this pump
// is simply done; never propagate onto this background thread.
}
}
public bool TryRequestGracefulStop()
{
try
@ -111,6 +180,21 @@ internal sealed class WindowsSystemChildProcess : ILauncherChildProcess
{
_standardInput?.Dispose();
_standardInput = null;
if (_stderrReadStream is not null)
{
// Closing the read end unblocks PumpStderr's pending Read()
// (ObjectDisposedException, caught there). The bounded Join
// lets that last in-flight chunk land in the capture file
// before it is disposed below, without letting a wedged pump
// thread ever hang this Dispose() call.
_stderrReadStream.Dispose();
_stderrReadStream = null;
_stderrPumpThread?.Join(TimeSpan.FromSeconds(2));
_stderrPumpThread = null;
}
_stderrCapture?.Dispose();
_stderrCapture = null;
if (_process is not null)
{
if (_raisingEnabled)
@ -225,18 +309,21 @@ internal sealed class WindowsProcessStartResult : IDisposable
private readonly SafeKernelHandle _processHandle;
private readonly SafeKernelHandle _threadHandle;
private SafeFileHandle? _standardInput;
private SafeFileHandle? _stderrRead;
private bool _resumed;
internal WindowsProcessStartResult(
int processId,
SafeKernelHandle processHandle,
SafeKernelHandle threadHandle,
SafeFileHandle standardInput)
SafeFileHandle standardInput,
SafeFileHandle? stderrRead = null)
{
ProcessId = processId;
_processHandle = processHandle;
_threadHandle = threadHandle;
_standardInput = standardInput;
_stderrRead = stderrRead;
}
internal int ProcessId { get; }
@ -263,6 +350,22 @@ internal sealed class WindowsProcessStartResult : IDisposable
}
}
/// <summary>
/// Transfers ownership of the parent-side stderr pipe read handle (fix
/// #406 sibling gap) — non-null only when <see cref="LauncherProcessSpec.StderrLogPath"/>
/// was set, in which case <see cref="WindowsProcessNative.StartCore"/>
/// created a real pipe for the child's stderr instead of the usual
/// duplicate-or-NUL handle. Returns null if capture was not requested,
/// or if this handle was already claimed. The caller owns disposal
/// after this call.
/// </summary>
internal SafeFileHandle? TakeStandardErrorRead()
{
SafeFileHandle? handle = _stderrRead;
_stderrRead = null;
return handle;
}
internal void Resume()
{
if (WindowsProcessNative.ResumeThread(_threadHandle) == uint.MaxValue)
@ -290,6 +393,7 @@ internal sealed class WindowsProcessStartResult : IDisposable
}
_standardInput?.Dispose();
_stderrRead?.Dispose();
_threadHandle.Dispose();
_processHandle.Dispose();
}
@ -361,13 +465,20 @@ internal static class WindowsProcessNative
private static WindowsProcessStartResult StartCore(LauncherProcessSpec spec)
{
SafeFileHandle? parentInput = null;
// fix #406 sibling gap: when the spec requests stderr capture, the
// child's stderr handle is a real pipe (this parent-side read end)
// instead of the usual duplicate-or-NUL handle below.
SafeFileHandle? parentStderrRead = null;
SafeHandle? childError = null;
try
{
using SafeFileHandle childInput = CreateChildInputPipe(
out SafeFileHandle createdParentInput);
parentInput = createdParentInput;
using SafeKernelHandle childOutput = DuplicateOrOpenNull(StdOutputHandle);
using SafeKernelHandle childError = DuplicateOrOpenNull(StdErrorHandle);
childError = string.IsNullOrWhiteSpace(spec.StderrLogPath)
? DuplicateOrOpenNull(StdErrorHandle)
: CreateChildOutputPipe(out parentStderrRead);
using var attributes = new ProcessThreadAttributeList(
childInput.DangerousGetHandle(),
childOutput.DangerousGetHandle(),
@ -420,8 +531,10 @@ internal static class WindowsProcessNative
checked((int)information.ProcessId),
processHandle,
threadHandle,
parentInput);
parentInput,
parentStderrRead);
parentInput = null;
parentStderrRead = null;
return result;
}
catch
@ -435,6 +548,8 @@ internal static class WindowsProcessNative
finally
{
parentInput?.Dispose();
parentStderrRead?.Dispose();
childError?.Dispose();
}
}
@ -525,6 +640,45 @@ internal static class WindowsProcessNative
return child;
}
/// <summary>
/// Mirror of <see cref="CreateChildInputPipe"/> with the roles
/// reversed (fix #406 sibling gap): the CHILD gets the pipe's WRITE
/// end (its stderr handle, inheritable across
/// <see cref="CreateProcessW"/>), the PARENT keeps the READ end
/// (inherit flag cleared, exactly like <paramref name="parentInput"/>'s
/// counterpart on the stdin pipe) so the launcher can drain the
/// child's stderr into a bounded file.
/// </summary>
private static SafeFileHandle CreateChildOutputPipe(out SafeFileHandle parentRead)
{
var security = new SecurityAttributes
{
Length = Marshal.SizeOf<SecurityAttributes>(),
InheritHandle = true,
};
if (!CreatePipe(out IntPtr read, out IntPtr write, ref security, 0))
{
throw new Win32Exception(Marshal.GetLastWin32Error(),
"The launcher child stderr pipe could not be created.");
}
var child = new SafeFileHandle(write, ownsHandle: true);
parentRead = new SafeFileHandle(read, ownsHandle: true);
if (!SetHandleInformation(
parentRead,
HandleFlagInherit,
0))
{
int error = Marshal.GetLastWin32Error();
child.Dispose();
parentRead.Dispose();
throw new Win32Exception(error,
"The launcher child stderr pipe could not be isolated.");
}
return child;
}
private static SafeKernelHandle DuplicateOrOpenNull(int standardHandle)
{
IntPtr source = GetStdHandle(standardHandle);

View file

@ -91,7 +91,8 @@ public sealed class LauncherExecutableSet
public LauncherProcessSpec CreatePlaySpec(
LaunchMode mode,
string configFilePath)
string configFilePath,
string? stderrLogPath = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
ExecutablePaths paths = RequireAvailable(mode);
@ -100,22 +101,27 @@ public sealed class LauncherExecutableSet
? new LauncherProcessSpec(
paths.HeadlessHostPath,
["--config", configFilePath],
paths.WorkingDirectory)
paths.WorkingDirectory,
StderrLogPath: stderrLogPath)
: new LauncherProcessSpec(
paths.GraphicalHostPath,
["--session-config", configFilePath],
paths.WorkingDirectory,
SupportsConsoleGracefulStop: false);
SupportsConsoleGracefulStop: false,
StderrLogPath: stderrLogPath);
}
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
public LauncherProcessSpec CreateProbeSpec(
string configFilePath,
string? stderrLogPath = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
ExecutablePaths paths = RequireAvailable(LaunchMode.Headless);
return new LauncherProcessSpec(
paths.HeadlessHostPath,
["--config", configFilePath],
paths.WorkingDirectory);
paths.WorkingDirectory,
StderrLogPath: stderrLogPath);
}
public static LauncherExecutableSet FromDirectory(string directory)

View file

@ -683,10 +683,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
request.Cancellation.Token.ThrowIfCancellationRequested();
LauncherProcessSpec processSpec = request.IsProbe
? _executables.CreateProbeSpec(composed.ConfigFilePath)
? _executables.CreateProbeSpec(
composed.ConfigFilePath,
composed.StderrLogPath)
: _executables.CreatePlaySpec(
request.Activity.LaunchMode!.Value,
composed.ConfigFilePath);
composed.ConfigFilePath,
composed.StderrLogPath);
supervisor.Start(processSpec, password);
hostStarted = true;