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);
}