Skip to content
24 changes: 23 additions & 1 deletion GenHub/GenHub.Core/Constants/ProcessConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,32 @@ public static class ProcessConstants
// Process discovery and timing constants

/// <summary>
/// Delay in milliseconds to wait before checking if a process has exited (launcher detection).
/// Minimum time in milliseconds a Windows launcher stub's child is given to appear,
/// measured from launch, before it is searched for.
/// </summary>
/// <remarks>
/// Formerly the fixed delay before the single exited-yet check. Exit detection now
/// waits on the process itself (see <see cref="PostSpawnExitDetectionWindowMs"/>),
/// which can observe a stub exiting well before 500 ms; this floor preserves the time
/// the fixed delay always gave the spawned game process to register.
/// </remarks>
public const int LauncherDetectionDelayMs = 500;

/// <summary>
/// Bounded window in milliseconds during which a just-started game process is watched
/// for an early exit before the launch is reported successful.
/// </summary>
/// <remarks>
/// Sized from measurement rather than guessed. The native Zero Hour client aborting
/// initialisation in an empty workspace exits 1 after roughly 0.8–0.9 s once warm
/// (macOS, Apple Silicon), so three seconds is ~3x the observed abort, absorbing slow
/// disks and emulation. The very first run of a freshly copied binary can take 3–5 s
/// because macOS validates the new inode before execution; an abort that slow falls
/// outside the window and is reported through the process-exited event instead of the
/// launch result.
/// </remarks>
public const int PostSpawnExitDetectionWindowMs = 3000;

/// <summary>
/// Interval in milliseconds for process cleanup / reconciliation background task.
/// </summary>
Expand Down
23 changes: 23 additions & 0 deletions GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,29 @@ public static class RetailArchiveConstants
/// <summary>Environment variable naming the Generals retail directory.</summary>
public const string GeneralsInstallPathVariable = "CNC_GENERALS_INSTALLPATH";

/// <summary>
/// Stderr line prefix the engine writes when an archive's identifier does not match.
/// The rest of the line is the archive path.
/// </summary>
/// <remarks>
/// Fork-only, like <see cref="ArchiveMountFailedStderrPrefix"/>: emitted by
/// <c>StdBIGFileSystem</c> on the bgfx fork and absent upstream on every platform,
/// including <c>Win32BIGFileSystem</c>. Both sentinels are therefore strictly
/// advisory — their absence means "this build does not emit one", never that the
/// launch was healthy.
/// </remarks>
public const string ArchiveIdentifierMismatchStderrPrefix = "[ggc] archive identifier mismatch: ";

/// <summary>
/// Stderr line prefix the engine writes when an archive cannot be mounted at all.
/// The rest of the line is the archive path.
/// </summary>
/// <remarks>
/// See <see cref="ArchiveIdentifierMismatchStderrPrefix"/> for why matching this is
/// advisory only.
/// </remarks>
public const string ArchiveMountFailedStderrPrefix = "[ggc] ARCHIVE MOUNT FAILED, contents unavailable this run: ";

/// <summary>
/// Search pattern for the archives the engine mounts from a retail root.
/// </summary>
Expand Down
70 changes: 70 additions & 0 deletions GenHub/GenHub.Core/Models/Events/GameProcessExitedEventArgs.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using GenHub.Core.Constants;

namespace GenHub.Core.Models.Events;

/// <summary>
Expand All @@ -19,4 +21,72 @@ public class GameProcessExitedEventArgs : EventArgs
/// Gets the time when the process exited.
/// </summary>
public DateTime ExitTime { get; init; } = DateTime.UtcNow;

/// <summary>
/// Gets the bounded tail of the process's captured standard error, when any was captured.
/// </summary>
/// <remarks>
/// Populated only for processes whose stderr the process manager was capturing, i.e.
/// ones it started itself. An initialisation abort slow enough to escape the
/// post-spawn detection window surfaces here, so subscribers can record why a launch
/// that was reported as started actually failed.
/// </remarks>
public string? StandardErrorTail { get; init; }

/// <summary>
/// Gets the archives named by the engine's mount-failure stderr sentinels, if any.
/// </summary>
/// <remarks>
/// Advisory: the sentinels are emitted only by the fork engine, so an empty list says
/// nothing about whether archives mounted.
/// </remarks>
public IReadOnlyList<string> UnmountableArchives { get; init; } = [];

/// <summary>
/// Gets a value indicating whether this exit was requested through the process
/// manager's terminate path before the kill was attempted.
/// </summary>
/// <remarks>
/// A killed process exits non-zero, which is otherwise the signature of a crash;
/// this flag is what lets consumers tell a deliberate stop apart from one.
/// </remarks>
public bool TerminationRequested { get; init; }

/// <summary>
/// Describes why this exit is a failure, or returns null for a clean or unknown exit.
/// </summary>
/// <remarks>
/// The single source of the late-failure wording: the launch registry records it and
/// the UI surfaces it, so composing it here keeps the two from drifting apart. The
/// advisory mount sentinels, when present, name the archive; otherwise the stderr
/// tail stands in. Only the non-zero exit code decides that the exit counts as a
/// failure — quitting the game cleanly is not one.
/// </remarks>
/// <returns>The failure description, or null when the exit is not a failure.</returns>
public string? DescribeFailure()
{
// A requested termination is never a failure, even though the kill produces a
// non-zero exit code. Trade-off, accepted deliberately: an engine that genuinely
// crashed moments before the user clicked Stop is suppressed too — a missed
// report of an already-dying process is preferred over false-alarming "exited
// unexpectedly" on every deliberate stop.
if (TerminationRequested)
{
return null;
}

if (ExitCode is not int exitCode || exitCode == ProcessConstants.ExitCodeSuccess)
{
return null;
}

if (UnmountableArchives.Count > 0)
{
return $"The game could not mount required archive(s): {string.Join(", ", UnmountableArchives)}. Process exited with code {exitCode} after launch.";
}

return StandardErrorTail is null
? $"Process exited with code {exitCode} after launch. No output was captured."
: $"Process exited with code {exitCode} after launch. {StandardErrorTail}";
}
}
18 changes: 18 additions & 0 deletions GenHub/GenHub.Core/Models/GameProfile/GameLaunchInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ public class GameLaunchInfo
/// <summary>Gets or sets the termination timestamp.</summary>
public DateTime? TerminatedAt { get; set; }

/// <summary>Gets or sets the process exit code, when it is known.</summary>
public int? ExitCode { get; set; }

/// <summary>
/// Gets or sets why this launch is considered failed, when the process exited
/// abnormally after the launch had already been reported as started.
/// </summary>
/// <remarks>
/// The late-failure channel: an initialisation abort slow enough to outlive the
/// post-spawn detection window cannot fail the start operation retroactively, so the
/// failure is recorded here instead. A clean exit leaves this null — quitting the
/// game is not a failed launch.
/// </remarks>
public string? FailureReason { get; set; }

/// <summary>Gets a value indicating whether this launch ended in failure.</summary>
public bool HasFailed => FailureReason != null;

/// <summary>Gets a value indicating whether the game is still running.</summary>
public bool IsRunning => TerminatedAt == null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public async Task RealNativeClient_LaunchesThroughGameProcessManager()

try
{
// StartProcessAsync only waits out the launcher-detection delay. Give the
// StartProcessAsync only waits out the post-spawn detection window. Give the
// engine long enough to fail the way it fails for real: mounting archives and
// initialising the renderer, both of which happen after the process exists.
await Task.Delay(LaunchSettleTime);
Expand Down Expand Up @@ -135,7 +135,7 @@ public async Task RealNativeClient_RequiresItsInstallDirectoryAsWorkingDirectory
// The expected path: it dies during startup and the failure names the reason
// rather than reporting a bare exit code.
Assert.False(result.Success);
Assert.Contains("exited immediately", string.Join(" ", result.Errors), StringComparison.OrdinalIgnoreCase);
Assert.Contains("exited during startup", string.Join(" ", result.Errors), StringComparison.OrdinalIgnoreCase);
}
finally
{
Expand Down
Loading
Loading