Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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