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
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue