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>
209 lines
7.2 KiB
C#
209 lines
7.2 KiB
C#
using System.Diagnostics;
|
|
using System.Runtime.InteropServices;
|
|
|
|
namespace AcDream.Launcher.Core.Launching;
|
|
|
|
/// <summary>
|
|
/// Thin seam over <see cref="System.Diagnostics.Process"/> so
|
|
/// <see cref="LauncherProcessSupervisor"/>'s lifecycle and Stop
|
|
/// (CloseMainWindow, falling back to Kill after a timeout) state machine
|
|
/// can be unit-tested against an in-memory fake without spawning a real
|
|
/// OS process or depending on real window-message timing — both
|
|
/// "injectable for tests" per Campaign LA spec §3.
|
|
/// </summary>
|
|
public interface ILauncherChildProcess : IDisposable
|
|
{
|
|
bool HasExited { get; }
|
|
|
|
int ExitCode { get; }
|
|
|
|
/// <summary>The child's redirected stdin. The supervisor writes the
|
|
/// account password here (followed by a newline) and then closes it —
|
|
/// never anywhere else.</summary>
|
|
TextWriter StandardInput { get; }
|
|
|
|
/// <summary>Fires exactly once, when the child process terminates
|
|
/// (mirrors <see cref="Process.Exited"/> with
|
|
/// <c>EnableRaisingEvents</c> on).</summary>
|
|
event EventHandler? Exited;
|
|
|
|
void Start();
|
|
|
|
/// <summary>
|
|
/// Attempts a graceful stop signal appropriate to the platform,
|
|
/// tried BEFORE <see cref="CloseMainWindow"/> (Campaign LA plan §LA3
|
|
/// review finding F3): a no-window console host (e.g.
|
|
/// <c>AcDream.Headless</c>) never has a main window for
|
|
/// <see cref="CloseMainWindow"/> to close, so without this step
|
|
/// <see cref="LauncherProcessSupervisor.Stop"/> always degraded
|
|
/// straight to a timeout + hard <see cref="Kill"/> — and a hard kill
|
|
/// leaves the ACE account session stuck for several minutes (a
|
|
/// documented project landmine; see CLAUDE.md
|
|
/// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved
|
|
/// the headless host's SIGINT handler produces an ACE-confirmed
|
|
/// graceful logout). On Windows, console-capable children are started
|
|
/// as distinct process-group leaders and receive a targeted
|
|
/// CTRL_BREAK_EVENT. Returns true only when the signal was actually
|
|
/// delivered; never throws.
|
|
/// </summary>
|
|
bool TryRequestGracefulStop();
|
|
|
|
/// <summary>Mirrors <see cref="Process.CloseMainWindow"/> — requests
|
|
/// a graceful close via WM_CLOSE. Returns false for a console/no-
|
|
/// window process (never throws), matching the real API.</summary>
|
|
bool CloseMainWindow();
|
|
|
|
/// <summary>Mirrors <see cref="Process.Kill(bool)"/> with
|
|
/// <c>entireProcessTree: true</c>.</summary>
|
|
void Kill();
|
|
|
|
bool WaitForExit(TimeSpan timeout);
|
|
}
|
|
|
|
/// <summary>Creates <see cref="ILauncherChildProcess"/> instances from a
|
|
/// <see cref="LauncherProcessSpec"/>.</summary>
|
|
public interface ILauncherChildProcessFactory
|
|
{
|
|
ILauncherChildProcess Create(LauncherProcessSpec spec);
|
|
}
|
|
|
|
/// <summary>Real-process implementation used in production.</summary>
|
|
public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory
|
|
{
|
|
public ILauncherChildProcess Create(LauncherProcessSpec spec) =>
|
|
OperatingSystem.IsWindows() && spec.SupportsConsoleGracefulStop
|
|
? new WindowsSystemChildProcess(spec)
|
|
: new SystemChildProcess(spec);
|
|
}
|
|
|
|
internal sealed partial class SystemChildProcess : ILauncherChildProcess
|
|
{
|
|
// SIGINT's numeric value (POSIX-stable across Linux distributions).
|
|
// K4/Slice K already proved the headless host's SIGINT handler
|
|
// produces an ACE-confirmed graceful logout.
|
|
private const int Sigint = 2;
|
|
|
|
[LibraryImport("libc", SetLastError = true)]
|
|
private static partial int kill(int pid, int sig);
|
|
|
|
private readonly Process _process;
|
|
private readonly bool _supportsConsoleGracefulStop;
|
|
private readonly BoundedProcessOutputCapture? _stderrCapture;
|
|
private bool _raisingEnabled;
|
|
private bool _errorReadingEnabled;
|
|
|
|
internal SystemChildProcess(LauncherProcessSpec spec)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(spec);
|
|
_supportsConsoleGracefulStop = spec.SupportsConsoleGracefulStop;
|
|
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = spec.ExecutablePath,
|
|
RedirectStandardInput = true,
|
|
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);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(spec.WorkingDirectory))
|
|
{
|
|
startInfo.WorkingDirectory = spec.WorkingDirectory;
|
|
}
|
|
|
|
_process = new Process { StartInfo = startInfo };
|
|
}
|
|
|
|
public bool HasExited => _process.HasExited;
|
|
|
|
public int ExitCode => _process.ExitCode;
|
|
|
|
public TextWriter StandardInput => _process.StandardInput;
|
|
|
|
public event EventHandler? Exited;
|
|
|
|
public void Start()
|
|
{
|
|
_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()
|
|
{
|
|
if (!OperatingSystem.IsLinux() || !_supportsConsoleGracefulStop)
|
|
{
|
|
// Windows console-capable children use
|
|
// WindowsSystemChildProcess. Graphical/non-console children
|
|
// deliberately retain the Process/WM_CLOSE path.
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
return kill(_process.Id, Sigint) == 0;
|
|
}
|
|
catch
|
|
{
|
|
// Matches CloseMainWindow's "never throws" contract — the
|
|
// process may not have started yet, may have already exited
|
|
// (ESRCH), or the platform may lack libc under an unusual
|
|
// Linux runtime; any of these degrade to "signal not sent"
|
|
// rather than an exception out of Stop().
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public bool CloseMainWindow() => _process.CloseMainWindow();
|
|
|
|
public void Kill() => _process.Kill(entireProcessTree: true);
|
|
|
|
public bool WaitForExit(TimeSpan timeout) => _process.WaitForExit(timeout);
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_raisingEnabled)
|
|
{
|
|
_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);
|
|
}
|