using System.Diagnostics; using System.Runtime.InteropServices; namespace AcDream.Launcher.Core.Launching; /// /// Thin seam over so /// '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. /// public interface ILauncherChildProcess : IDisposable { bool HasExited { get; } int ExitCode { get; } /// The child's redirected stdin. The supervisor writes the /// account password here (followed by a newline) and then closes it — /// never anywhere else. TextWriter StandardInput { get; } /// Fires exactly once, when the child process terminates /// (mirrors with /// EnableRaisingEvents on). event EventHandler? Exited; void Start(); /// /// Attempts a graceful stop signal appropriate to the platform, /// tried BEFORE (Campaign LA plan §LA3 /// review finding F3): a no-window console host (e.g. /// AcDream.Headless) never has a main window for /// to close, so without this step /// always degraded /// straight to a timeout + hard — 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. /// bool TryRequestGracefulStop(); /// Mirrors — requests /// a graceful close via WM_CLOSE. Returns false for a console/no- /// window process (never throws), matching the real API. bool CloseMainWindow(); /// Mirrors with /// entireProcessTree: true. void Kill(); bool WaitForExit(TimeSpan timeout); } /// Creates instances from a /// . public interface ILauncherChildProcessFactory { ILauncherChildProcess Create(LauncherProcessSpec spec); } /// Real-process implementation used in production. 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); }