diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 620f8a1..c0e2703 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -61,6 +61,28 @@ jobs:
--no-build
--no-restore
+ process-signal-stress:
+ name: Process Signal Stress
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
+ with:
+ dotnet-version: '10.0.x'
+ dotnet-quality: ga
+
+ # The script restores and builds on its own. Low iteration counts keep the per-PR cost small;
+ # the epoch races it guards surface quickly. When chasing a flake, run it locally with more:
+ # ./eng/ci/process-signal-stress.sh 50 20
+ - name: Run process-signal stress
+ shell: bash
+ run: bash ./eng/ci/process-signal-stress.sh 3 2
+
shell-completion-real-shells:
name: Shell Completion Smoke (Real Shells)
runs-on: ubuntu-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 57bcb2f..abccc39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,36 @@ Nerdbank.GitVersioning at pack time; this file groups changes by theme instead o
## Unreleased
+### Added — standalone process signals
+
+- `ReplRunOptions.ProcessSignalHandling` and `ProcessSignalHandlingMode` let internally configured standalone `Run`/`RunAsync` calls opt into or out of cooperative process-signal handling. The nullable option inherits the active profile default: CLI and default-interactive profiles use `Automatic`; an unprofiled `ReplApp.Create()` and `UseEmbeddedConsoleProfile()` use `None`, preserving caller-owned shutdown unless a process-owning profile is selected.
+- In automatic mode, the first Ctrl+C console event—or Ctrl+Break on Windows—cancels all overlapping standalone runs in one process-wide ownership epoch and resolves a successful or cancelled run to exit code `130`. On supported Unix platforms, SIGTERM behaves the same way with exit code `143`. A subsequent signal uses the operating-system default, and stderr diagnostics identify both steps. Explicit non-zero handler exit codes remain authoritative.
+
+### Changed — process signal ownership
+
+- Apps that select `UseCliProfile()` or `UseDefaultInteractive()` now take process signal ownership by
+ default. Two observable changes follow for an existing consumer. Selecting
+ `ProcessSignalHandlingMode.None` restores the previous behavior:
+ - **Exit codes.** A run interrupted by Ctrl+C, Ctrl+Break on Windows, or SIGTERM on Unix now resolves
+ to `130` or `143` where it previously produced whatever the operating-system default termination
+ yielded. A wrapper script or CI step that treats any non-zero code as a failure will start seeing
+ these on interruption. An explicit non-zero handler exit code still takes precedence.
+ - **Handler token identity.** One-shot handlers now receive a run-scoped token linked to the caller
+ token instead of the caller token itself, and Repl disposes it when the run ends. No token Repl
+ creates may outlive its run. A handler that stored one and used it afterwards — for detached or
+ background work — sees `ObjectDisposedException` from `Register` or `WaitHandle`, and, worse,
+ nothing at all from `IsCancellationRequested`, which keeps reporting `false`. Handlers that only
+ await work within the run are unaffected. Apps with no profile, `UseEmbeddedConsoleProfile()`, and
+ the external `IServiceProvider`/`IHost`/`IReplHost` overloads keep passing the caller token through
+ unchanged.
+
+### Operational notes — process signals
+
+- Exit codes `130` (`128 + SIGINT(2)`) and `143` (`128 + SIGTERM(15)`) follow the widely adopted Unix/Bash convention; they are not universal .NET or Windows exit-code guarantees. SIGTERM bridging is Unix-only.
+- Automatic handling has no built-in grace-period timeout. A supervisor can send a second signal to force termination. The process callbacks are installed lazily once and remain inert outside automatic runs so runtime callback snapshots cannot race handler teardown.
+- For one-shot handlers, automatic mode injects a linked, run-scoped token, while external host/provider overloads pass the caller token through unchanged. Interactive commands receive a separate command-scoped linked token so Ctrl+C can cancel only the active command. Handlers must not retain any Repl-created token beyond its scope. An explicit `Automatic` request on an external overload is ignored with a diagnostic on the active error channel.
+- Android, browser, iOS (including Mac Catalyst), and tvOS do not install the unsupported process-signal bridge; `Automatic` emits a diagnostic and their platform host must provide cancellation. Consumer cancellation-callback failures are also diagnosed without replacing an established `130`/`143` exit policy.
+
### Added — option visibility
- `.Hidden(bool isHidden = true)` on the option builder (`WithOption(name, option => option.Hidden())`)
diff --git a/docs/best-practices.md b/docs/best-practices.md
index 9bf3f90..195927a 100644
--- a/docs/best-practices.md
+++ b/docs/best-practices.md
@@ -307,4 +307,24 @@ app.Map("dashboard", static async (
That keeps status/progress/problem events out of the main Spectre surface and avoids terminal control sequences fighting with your TUI.
+## Own process signals exactly once
+
+Use `UseCliProfile()` (or an explicit `ProcessSignalHandlingMode.Automatic`) for a standalone CLI where Repl is the process owner. An unprofiled `ReplApp.Create()` remains caller-owned. Use `UseEmbeddedConsoleProfile()` or explicitly set `ProcessSignalHandlingMode.None` when an ASP.NET Core host, worker service, test runner, or another command framework already owns console cancellation and shutdown. Feed that host's cancellation token into `RunAsync` instead of installing competing handlers. External `IServiceProvider`, `IHost`, and `IReplHost` overloads always remain caller-owned and diagnose an explicit `Automatic` request instead of applying it.
+
+Supplying a `ReplRunOptions` instance for an unrelated setting preserves the profile default because `ProcessSignalHandling` is nullable:
+
+```csharp
+var app = ReplApp.Create().UseEmbeddedConsoleProfile();
+
+return await app.RunAsync(
+ args,
+ new ReplRunOptions
+ {
+ AnsiSupport = AnsiMode.Never,
+ },
+ hostStoppingToken);
+```
+
+A one-shot handler token injected during `Automatic` handling is run-scoped; an interactive command receives a shorter-lived token linked to that run token. Await all work that uses either token before returning, and do not capture it for detached background work. See [Process signal handling](configuration-reference.md#process-signal-handling) for first/second-signal behavior, exit-code conventions, and platform limits.
+
See also: [Modules](module-presence.md) | [Route System](route-system.md) | [MCP Overview](mcp-overview.md) | [Testing](testing-toolkit.md) | [Configuration](configuration-reference.md)
diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md
index c05491b..e44c22e 100644
--- a/docs/configuration-reference.md
+++ b/docs/configuration-reference.md
@@ -182,6 +182,144 @@ Accessed via `ReplOptions.ShellCompletion`. See [Shell Completion](shell-complet
A record passed to `app.RunAsync(...)` to control runtime behavior. Separate from `ReplOptions`.
+- `ProcessSignalHandling` (`ProcessSignalHandlingMode?`, default: `null`) — `null` preserves the active application's profile default. Set it to `Automatic` or `None` to override that default for one run. An unprofiled app defaults to caller-owned handling (`None`).
- `HostedServiceLifecycle` (`HostedServiceLifecycleMode`, default: `None`) — Hosted service lifecycle mode.
- `AnsiSupport` (`AnsiMode`, default: `Auto`) — ANSI support mode for this run.
- `TerminalOverrides` (`TerminalSessionOverrides?`, default: `null`) — Terminal session overrides.
+
+### Process signal handling
+
+`ProcessSignalHandling` applies only to standalone `Run`/`RunAsync` overloads that use the app's internally configured services. Overloads that receive an external `IServiceProvider`, `IHost`, or `IReplHost` do not install the standalone process-signal bridge; the external owner remains responsible for translating shutdown into the caller-owned cancellation token. Passing an explicit `Automatic` value to one of those overloads writes a diagnostic to the active error channel and ignores the value. If such a run enters Repl's interactive loop, that loop still retains its own console command-cancellation policy.
+
+The mode that actually applies to a run is resolved in this order:
+
+```mermaid
+flowchart TD
+ A["Run / RunAsync"] --> B{"Which overload?"}
+ B -->|"External IServiceProvider, IHost or IReplHost"| C["Caller-owned
an explicit Automatic is diagnosed and ignored"]
+ B -->|"Internally configured services"| D{"ReplRunOptions.ProcessSignalHandling"}
+ D -->|"None"| E["Caller-owned
no bridge is installed"]
+ D -->|"Automatic"| G{"Is the bridge available?"}
+ D -->|"null (default)"| F["Active profile default"]
+ F -->|"UseCliProfile / UseDefaultInteractive"| G
+ F -->|"no profile / UseEmbeddedConsoleProfile"| E
+ G -->|"yes"| H["Repl owns signals for this run
the handler receives a linked run-scoped token"]
+ G -->|"Android, browser, iOS incl. Mac Catalyst, tvOS"| I["Diagnostic, then no bridge
the handler still receives a linked run-scoped token"]
+ G -->|"registration rejected by the environment"| I
+```
+
+| Value | Behavior |
+|---|---|
+| `null` | Inherit the active profile's default. Supplying unrelated options such as `AnsiSupport` does not change signal ownership. |
+| `ProcessSignalHandlingMode.Automatic` | Repl temporarily owns standalone process-signal handling and converts a first supported signal into cooperative cancellation. |
+| `ProcessSignalHandlingMode.None` | Repl installs no standalone process-signal handling. The caller or host owns shutdown. |
+
+Profile defaults are:
+
+| App configuration | Default | Intended owner |
+|---|---|---|
+| `ReplApp.Create()` without a profile | `None` | Caller or embedding host |
+| `UseCliProfile()` | `Automatic` | Standalone CLI process |
+| `UseDefaultInteractive()` | `Automatic` for one-shot runs; the interactive session keeps its existing Ctrl+C behavior | Repl |
+| `UseEmbeddedConsoleProfile()` | `None` | Embedding host |
+
+An embedded host can opt in for one run, while a standalone app can opt out:
+
+```csharp
+var exitCode = await app.RunAsync(
+ args,
+ new ReplRunOptions
+ {
+ ProcessSignalHandling = ProcessSignalHandlingMode.Automatic,
+ },
+ stoppingToken);
+```
+
+```csharp
+var exitCode = await app.RunAsync(
+ args,
+ new ReplRunOptions
+ {
+ ProcessSignalHandling = ProcessSignalHandlingMode.None,
+ },
+ stoppingToken);
+```
+
+#### First and second signals
+
+Automatic handling supports overlapping standalone runs in one process-wide ownership epoch. The shared OS callbacks are installed lazily once per process and remain inert when no automatic run owns signals; keeping the callbacks stable avoids registration teardown races with runtime callback snapshots.
+
+1. The first supported signal is claimed once, a diagnostic is written to standard error, and every active automatic run receives cooperative cancellation. A run that starts before the last scope from that epoch is disposed joins the already-cancelled epoch rather than interpreting the next signal as another first signal.
+2. A subsequent supported signal is not suppressed. Repl writes a final diagnostic and leaves termination to the operating system, so cleanup is not guaranteed to finish.
+3. After the last automatic scope is disposed **and all signal-triggered cancellation callbacks have drained**, the process-wide claimed-signal state resets. A run that joins while callbacks are still draining inherits the cancelled epoch.
+
+The epoch is process-wide, so its state is easier to read as a machine than as a list:
+
+```mermaid
+stateDiagram-v2
+ direction LR
+ [*] --> Inert
+
+ Inert --> Unclaimed: a run starts
+ Unclaimed --> Inert: last run disposed
+ Unclaimed --> Claimed: step 1
+ Claimed --> Claimed: a run starts
+ Claimed --> Inert: step 3
+ Claimed --> [*]: step 2
+
+ note right of Inert
+ OS callbacks are installed lazily on the first
+ automatic run, then stay installed. If the
+ platform or the environment refuses them, runs
+ still start and stop but no signal can reach
+ this machine, so it never reaches Claimed.
+ end note
+
+ note right of Claimed
+ Late joiners inherit the cancelled epoch
+ instead of reading the next signal as a
+ new first signal.
+ end note
+```
+
+The step numbers are the three above. Two edges are worth reading twice: `Claimed --> [*]` is the operating system terminating the process, not Repl returning an exit code; and `Claimed --> Inert` waits on cancellation-callback draining as well as scope disposal, neither of which is bounded. That is deliberate — see the paragraph below the priority rule.
+
+Interactive console-key handling has priority over standalone handling: the first Ctrl+C event—or Ctrl+Break on Windows—during an interactive command cancels that command; a subsequent event, or one with no active command, retains the operating-system default.
+
+One `Console.CancelKeyPress` subscription serves both owners, and which key counts depends on the platform:
+
+```mermaid
+flowchart TD
+ A["Console.CancelKeyPress"] --> B{"Special key"}
+ B -->|"ControlC"| D
+ B -->|"ControlBreak on Windows"| D
+ B -->|"ControlBreak on Unix, i.e. SIGQUIT"| C["Unclaimed
OS default applies"]
+ D{"An interactive handler is registered?"}
+ D -->|"yes"| E["Interactive handler decides
first press cancels the running command"]
+ D -->|"no"| F{"An automatic standalone run is active?"}
+ F -->|"yes"| G["The standalone epoch claims it
see the epoch machine above"]
+ F -->|"no"| C
+```
+
+Repl does **not** impose an automatic grace-period timeout after the first signal. A non-cooperative handler can therefore keep running until another signal is sent or an external supervisor escalates termination. Cancellation-callback draining is likewise unbounded: resetting the epoch while a callback is still running could cause the next signal to be suppressed as a new first signal. If a callback never completes, the epoch remains claimed and every subsequent supported signal falls through to operating-system termination. This avoids embedding an application-specific shutdown deadline in the library.
+
+#### Exit codes
+
+| Signal/event | Typical source | Exit code | Basis |
+|---|---|---:|---|
+| `SIGINT` | Ctrl+C | `130` | Unix convention: `128 + 2` |
+| `ConsoleSpecialKey.ControlBreak` | Ctrl+Break on Windows | `130` | Repl compatibility policy |
+| `SIGTERM` | Service manager, container runtime, or `kill` | `143` | Unix convention: `128 + 15` |
+| `SIGQUIT` | Ctrl+\ on Unix, or `kill -QUIT` | `131` | Unclaimed by Repl; whatever the operating system produces |
+
+The `128 + signal number` calculation is a widely adopted Unix shell convention, notably used by Bash. It is not a universal .NET exit-code standard, and POSIX requires signal termination statuses to be distinguishable without requiring this exact arithmetic on every shell and platform. Repl deliberately returns `130` or `143` for predictable Unix CLI, script, container, and supervisor integration.
+
+If a handler completes normally with its own non-zero exit code, that code takes precedence. A successful `0` result or an `OperationCanceledException` caused by the claimed signal resolves to the signal code. Exceptions thrown by consumer cancellation callbacks are observed and diagnosed during scope disposal but do not replace an already-established signal exit code.
+
+#### Platform scope and token lifetime
+
+- Ctrl+C is bridged through `Console.CancelKeyPress`. Ctrl+Break follows the same Repl policy only on Windows. On Unix, .NET surfaces SIGQUIT through `Console.CancelKeyPress` as `ControlBreak`; Repl leaves that event unclaimed so the operating-system SIGQUIT behavior is preserved.
+- SIGTERM bridging uses .NET's POSIX signal API and is enabled only on supported non-Windows platforms. SIGTERM does not participate in the interactive console-key priority rule. Repl does not install a direct POSIX SIGQUIT registration. Windows `taskkill`, console-window close, and service-control shutdown do not acquire equivalent SIGTERM semantics from this option; a Windows host must translate its lifecycle events into the caller cancellation token.
+- Android, browser, iOS (including Mac Catalyst), and tvOS do not support the required console/POSIX registrations. `Automatic` emits a diagnostic and installs no process-signal bridge there; the platform host must provide cancellation. .NET identifies Mac Catalyst as part of its iOS-like mobile family and compiles the platform-not-supported POSIX signal registration there.
+- In `Automatic` mode, a one-shot handler receives a run-scoped token linked to the caller token and the process-signal cancellation source. An interactive command receives a command-scoped token linked to that run token so Ctrl+C can cancel only the active command. Repl disposes each linked token when its scope ends; handlers may use it for awaited work but must not retain it for detached work.
+- In `None` mode and external-host overloads, Repl does not create the standalone signal-linked token. A one-shot handler receives the caller token unchanged. An interactive command still receives its separate command-scoped linked token, so its identity and lifetime differ from the caller token even though host-shutdown cancellation flows through it.
diff --git a/eng/ci/process-signal-stress.sh b/eng/ci/process-signal-stress.sh
new file mode 100755
index 0000000..580bb6d
--- /dev/null
+++ b/eng/ci/process-signal-stress.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+set -euo pipefail
+set -E
+trap 'echo "Process-signal stress failed at line ${LINENO}" >&2' ERR
+
+unit_iterations="${1:-50}"
+integration_iterations="${2:-20}"
+configuration="${CONFIGURATION:-Release}"
+
+for value in "$unit_iterations" "$integration_iterations"; do
+ if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
+ echo "usage: $0 [unit-iterations] [integration-iterations]" >&2
+ exit 2
+ fi
+done
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$repo_root"
+
+log_file="$(mktemp)"
+trap 'rm -f "$log_file"' EXIT
+
+# Restore explicitly, then build without one. An incremental restore audits no projects, which trips
+# the CI-only NuGet audit assertion in src/Directory.Solution.targets when the caller already restored.
+dotnet restore src/Repl.slnx --force
+
+dotnet build src/Repl.slnx \
+ -c "$configuration" \
+ -warnaserror \
+ --no-restore \
+ --nologo
+
+run_stress() {
+ local label="$1"
+ local iterations="$2"
+ local project="$3"
+ local filter="$4"
+ local minimum_tests="$5"
+
+ for ((iteration = 1; iteration <= iterations; iteration++)); do
+ if ! dotnet test --project "$project" \
+ -c "$configuration" \
+ --no-build \
+ --no-restore \
+ --no-ansi \
+ --filter "$filter" \
+ --minimum-expected-tests "$minimum_tests" >"$log_file" 2>&1; then
+ echo "$label failed on iteration $iteration/$iterations" >&2
+ cat "$log_file" >&2
+ return 1
+ fi
+ done
+
+ echo "$label: $iterations/$iterations iterations passed ($minimum_tests tests each)"
+}
+
+run_stress \
+ "process-signal unit stress" \
+ "$unit_iterations" \
+ src/Repl.Tests/Repl.Tests.csproj \
+ "FullyQualifiedName~Given_ProcessSignalCancellationScope" \
+ 25
+
+run_stress \
+ "process-signal integration stress" \
+ "$integration_iterations" \
+ src/Repl.IntegrationTests/Repl.IntegrationTests.csproj \
+ "FullyQualifiedName~Given_ProcessSignals" \
+ 8
diff --git a/src/Repl.Core/Console/CancelKeyHandler.cs b/src/Repl.Core/Console/CancelKeyHandler.cs
index 120cd61..4c3113c 100644
--- a/src/Repl.Core/Console/CancelKeyHandler.cs
+++ b/src/Repl.Core/Console/CancelKeyHandler.cs
@@ -1,29 +1,23 @@
namespace Repl;
///
-/// Session-scoped Ctrl+C handler that implements double-tap cancellation.
-///
-/// - 1st Ctrl+C during a command → cancels the per-command CTS, session continues.
-/// - 2nd Ctrl+C within ~2 s (or Ctrl+C with no active command) → exits the process.
-///
-/// Uses which works universally across terminals,
-/// IDEs (Rider, VS Code), SSH sessions, and tmux — unlike Esc-key polling.
+/// Session-scoped Ctrl+C handler. The first press during a command cancels its CTS and keeps
+/// the session alive; a subsequent press, or a press with no active command, exits the process.
+/// Registers its claim with so interactive and standalone
+/// handlers share one atomic, process-wide ownership decision.
///
internal sealed class CancelKeyHandler : IDisposable
{
- private static readonly TimeSpan DoubleTapWindow = TimeSpan.FromSeconds(2);
-
private CancellationTokenSource? _commandCts;
- private DateTimeOffset _lastCancelPress;
private readonly Lock _lock = new();
- private readonly bool _hooked;
+ private readonly IDisposable? _registration;
+ private int _disposed;
internal CancelKeyHandler()
{
- _hooked = !ReplSessionIO.IsSessionActive;
- if (_hooked)
+ if (!ReplSessionIO.IsSessionActive)
{
- Console.CancelKeyPress += OnCancelKeyPress;
+ _registration = ConsoleCancelKeyCoordinator.RegisterInteractive(TryHandleCancelKey);
}
}
@@ -41,36 +35,31 @@ internal void SetCommandCts(CancellationTokenSource? cts)
public void Dispose()
{
- if (_hooked)
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
- Console.CancelKeyPress -= OnCancelKeyPress;
+ return;
}
+
+ _registration?.Dispose();
}
- private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
+ internal ConsoleCancelKeyHandlingResult HandleCancelKeyForTesting() => TryHandleCancelKey();
+
+ private ConsoleCancelKeyHandlingResult TryHandleCancelKey()
{
lock (_lock)
{
- var now = DateTimeOffset.UtcNow;
-
if (_commandCts is { IsCancellationRequested: false })
{
- // First Ctrl+C during a command → cancel command, keep session alive.
- e.Cancel = true;
_commandCts.Cancel();
- _lastCancelPress = now;
ReplSessionIO.Error.WriteLine();
ReplSessionIO.Error.WriteLine("Press Ctrl+C again to exit.");
- return;
- }
-
- if (now - _lastCancelPress < DoubleTapWindow)
- {
- // Second Ctrl+C within window → exit (don't set e.Cancel).
- return;
+ return ConsoleCancelKeyHandlingResult.SuppressProcessTermination;
}
- // Ctrl+C with no active command → exit.
+ // A subsequent press, or a press with no active command, retains
+ // the operating-system default instead of handing ownership to a standalone run.
+ return ConsoleCancelKeyHandlingResult.AllowProcessTermination;
}
}
}
diff --git a/src/Repl.Core/Console/ConsoleCancelKeyCoordinator.cs b/src/Repl.Core/Console/ConsoleCancelKeyCoordinator.cs
new file mode 100644
index 0000000..81ef417
--- /dev/null
+++ b/src/Repl.Core/Console/ConsoleCancelKeyCoordinator.cs
@@ -0,0 +1,158 @@
+namespace Repl;
+
+///
+/// Arbitrates console cancel-key ownership through one lazily installed, process-lifetime
+/// subscription. Interactive handlers take priority over
+/// standalone handlers. Registration and removal are atomic with respect to selection snapshots.
+/// If ownership changes before selection is validated, the coordinator reselects once; after
+/// validation, the retained callbacks own that in-flight occurrence even if their registrations
+/// are concurrently removed. No consumer callback runs while the coordinator gate is held.
+///
+internal static class ConsoleCancelKeyCoordinator
+{
+ private static readonly Lock Gate = new();
+ private static readonly Dictionary> InteractiveHandlers = [];
+ private static readonly Dictionary> StandaloneHandlers = [];
+ private static long s_nextRegistrationId;
+ private static long s_registrationVersion;
+ private static bool s_isSubscribed;
+
+ internal static IDisposable RegisterInteractive(Func handler) =>
+ Register(handler, isInteractive: true);
+
+ internal static IDisposable RegisterStandalone(Func handler) =>
+ Register(handler, isInteractive: false);
+
+ private static Registration Register(
+ Func handler,
+ bool isInteractive)
+ {
+ ArgumentNullException.ThrowIfNull(handler);
+ lock (Gate)
+ {
+ EnsureSubscribed();
+ var registrationId = ++s_nextRegistrationId;
+ var handlers = isInteractive ? InteractiveHandlers : StandaloneHandlers;
+ handlers.Add(registrationId, handler);
+ s_registrationVersion++;
+ return new Registration(registrationId, isInteractive);
+ }
+ }
+
+ private static void EnsureSubscribed()
+ {
+ if (s_isSubscribed)
+ {
+ return;
+ }
+
+ Console.CancelKeyPress += OnCancelKeyPress;
+ s_isSubscribed = true;
+ }
+
+ private static void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
+ {
+ if (HandleCancelKey(e.SpecialKey, OperatingSystem.IsWindows())
+ == ConsoleCancelKeyHandlingResult.SuppressProcessTermination)
+ {
+ e.Cancel = true;
+ }
+ }
+
+ internal static ConsoleCancelKeyHandlingResult HandleCancelKeyForTesting(
+ ConsoleSpecialKey specialKey = ConsoleSpecialKey.ControlC,
+ Action? afterInitialSelection = null,
+ bool? isWindows = null) =>
+ HandleCancelKey(specialKey, isWindows ?? OperatingSystem.IsWindows(), afterInitialSelection);
+
+ private static ConsoleCancelKeyHandlingResult HandleCancelKey(
+ ConsoleSpecialKey specialKey,
+ bool isWindows,
+ Action? afterInitialSelection = null)
+ {
+ if (!IsHandledCancelKey(specialKey, isWindows))
+ {
+ return ConsoleCancelKeyHandlingResult.NotHandled;
+ }
+
+ var selection = CaptureSelection();
+ afterInitialSelection?.Invoke();
+ return Invoke(RevalidateSelection(selection).Handlers);
+ }
+
+ private static bool IsHandledCancelKey(ConsoleSpecialKey specialKey, bool isWindows) =>
+ specialKey == ConsoleSpecialKey.ControlC
+ || (isWindows && specialKey == ConsoleSpecialKey.ControlBreak);
+
+ private static DispatchSelection CaptureSelection()
+ {
+ lock (Gate)
+ {
+ return CaptureSelectionUnsafe();
+ }
+ }
+
+ private static DispatchSelection RevalidateSelection(DispatchSelection selection)
+ {
+ lock (Gate)
+ {
+ return selection.Version == s_registrationVersion
+ ? selection
+ : CaptureSelectionUnsafe();
+ }
+ }
+
+ private static DispatchSelection CaptureSelectionUnsafe()
+ {
+ var handlers = InteractiveHandlers.Count > 0
+ ? InteractiveHandlers.Values
+ : StandaloneHandlers.Values;
+ return new DispatchSelection(s_registrationVersion, [.. handlers]);
+ }
+
+ private static ConsoleCancelKeyHandlingResult Invoke(
+ IReadOnlyList> handlers)
+ {
+ var result = ConsoleCancelKeyHandlingResult.NotHandled;
+ foreach (var handler in handlers)
+ {
+ result = handler() switch
+ {
+ ConsoleCancelKeyHandlingResult.SuppressProcessTermination =>
+ ConsoleCancelKeyHandlingResult.SuppressProcessTermination,
+ ConsoleCancelKeyHandlingResult.AllowProcessTermination
+ when result == ConsoleCancelKeyHandlingResult.NotHandled =>
+ ConsoleCancelKeyHandlingResult.AllowProcessTermination,
+ _ => result,
+ };
+ }
+
+ return result;
+ }
+
+ private readonly record struct DispatchSelection(
+ long Version,
+ IReadOnlyList> Handlers);
+
+ private sealed class Registration(long registrationId, bool isInteractive) : IDisposable
+ {
+ private int _disposed;
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
+ {
+ return;
+ }
+
+ lock (Gate)
+ {
+ var handlers = isInteractive ? InteractiveHandlers : StandaloneHandlers;
+ if (handlers.Remove(registrationId))
+ {
+ s_registrationVersion++;
+ }
+ }
+ }
+ }
+}
diff --git a/src/Repl.Core/Console/ConsoleCancelKeyHandlingResult.cs b/src/Repl.Core/Console/ConsoleCancelKeyHandlingResult.cs
new file mode 100644
index 0000000..0e887db
--- /dev/null
+++ b/src/Repl.Core/Console/ConsoleCancelKeyHandlingResult.cs
@@ -0,0 +1,15 @@
+namespace Repl;
+
+///
+/// Describes whether a console cancel-key dispatch had an owner and whether that owner claimed the key.
+/// The two places that decide whether to suppress termination act only on
+/// , so the other two agree on the outcome. They stay distinct
+/// because aggregation across handlers has to tell an owner that intentionally allowed termination from
+/// no owner at all, and tests assert that difference.
+///
+internal enum ConsoleCancelKeyHandlingResult
+{
+ NotHandled,
+ SuppressProcessTermination,
+ AllowProcessTermination,
+}
diff --git a/src/Repl.Defaults/ProcessSignalCancellationScope.cs b/src/Repl.Defaults/ProcessSignalCancellationScope.cs
new file mode 100644
index 0000000..9bf286a
--- /dev/null
+++ b/src/Repl.Defaults/ProcessSignalCancellationScope.cs
@@ -0,0 +1,128 @@
+namespace Repl;
+
+///
+/// Represents one disposable lease in 's process-wide
+/// ownership epoch. A first claimed signal records its conventional exit code and reserves a
+/// cancellation-delivery task before callbacks start outside the coordinator gate. Disposal
+/// withdraws the active lease, drains that task, and only then disposes the linked token source.
+///
+internal sealed class ProcessSignalCancellationScope : IAsyncDisposable
+{
+ private readonly CancellationTokenSource _linkedCancellation;
+ private readonly Lock _gate = new();
+ private Task _cancellationTask = Task.CompletedTask;
+ private int? _exitCode;
+ private bool _disposed;
+ private int _disposeStarted;
+
+ public ProcessSignalCancellationScope(CancellationToken cancellationToken)
+ {
+ _linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+
+ try
+ {
+ ProcessSignalCoordinator.Register(this);
+ }
+ catch
+ {
+ _linkedCancellation.Dispose();
+ throw;
+ }
+ }
+
+ public CancellationToken Token => _linkedCancellation.Token;
+
+ public int? ExitCode
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _exitCode;
+ }
+ }
+ }
+
+ public int ResolveExitCode(int runExitCode)
+ {
+ var signalExitCode = ExitCode;
+ return runExitCode != 0 || signalExitCode is null ? runExitCode : signalExitCode.Value;
+ }
+
+ public ValueTask DisposeAsync() => DisposeCoreAsync(afterWinningDisposal: null);
+
+ internal ValueTask DisposeForTestingAsync(Action afterWinningDisposal)
+ {
+ ArgumentNullException.ThrowIfNull(afterWinningDisposal);
+ return DisposeCoreAsync(afterWinningDisposal);
+ }
+
+ private async ValueTask DisposeCoreAsync(Action? afterWinningDisposal)
+ {
+ if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
+ {
+ return;
+ }
+
+ afterWinningDisposal?.Invoke();
+ try
+ {
+ var cancellationCallbackException = await ProcessSignalCoordinator.UnregisterAsync(this)
+ .ConfigureAwait(false);
+ if (cancellationCallbackException is not null)
+ {
+ ProcessSignalCoordinator.WriteDiagnostic(
+ "A process-signal cancellation callback failed after the signal exit policy "
+ + $"was established: {cancellationCallbackException}");
+ }
+ }
+ finally
+ {
+ _linkedCancellation.Dispose();
+ }
+ }
+
+ internal Action? PrepareSignalCancellation(int exitCode)
+ {
+ lock (_gate)
+ {
+ if (_disposed || _exitCode is not null)
+ {
+ return null;
+ }
+
+ _exitCode = exitCode;
+ // Reserve the eventual CancelAsync task under this gate so DisposeAsync cannot miss it.
+ // The outer TCS is completed only after the process coordinator gate is released, which
+ // guarantees that no consumer cancellation callback runs while either gate is held.
+ var cancellationTaskSource = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ _cancellationTask = cancellationTaskSource.Task.Unwrap();
+ return () =>
+ {
+ Task cancellationTask;
+ try
+ {
+ cancellationTask = _linkedCancellation.CancelAsync();
+ }
+ catch (Exception ex)
+ {
+ cancellationTask = Task.FromException(ex);
+ }
+
+ cancellationTaskSource.TrySetResult(cancellationTask);
+ };
+ }
+ }
+
+ internal Task MarkDisposedAndGetCancellationTaskAsync()
+ {
+ lock (_gate)
+ {
+ _disposed = true;
+#pragma warning disable VSTHRD003 // The coordinator needs the reserved cancellation task so disposal can drain it.
+ return _cancellationTask;
+#pragma warning restore VSTHRD003
+ }
+ }
+}
diff --git a/src/Repl.Defaults/ProcessSignalCoordinator.cs b/src/Repl.Defaults/ProcessSignalCoordinator.cs
new file mode 100644
index 0000000..1c1e135
--- /dev/null
+++ b/src/Repl.Defaults/ProcessSignalCoordinator.cs
@@ -0,0 +1,329 @@
+using System.Runtime.InteropServices;
+
+namespace Repl;
+
+///
+/// Owns the process-wide standalone signal protocol. The first SIGINT (or SIGTERM on Unix)
+/// atomically claims the current ownership epoch and prepares cancellation for every active scope;
+/// later scopes join that draining epoch, and any subsequent signal is left to the operating-system
+/// default. The epoch resets only after its final scope and all signal-triggered cancellation work
+/// have drained. Interactive console-key ownership is selected first by
+/// . Its gate is always released before this coordinator is
+/// invoked. This coordinator may acquire an individual scope gate, but a scope never enters this
+/// coordinator while holding its gate; reserved consumer callbacks start only after both gates are
+/// released. Process registrations are installed lazily once and remain inert without active scopes.
+///
+internal static class ProcessSignalCoordinator
+{
+ internal const int SigIntExitCode = 130;
+ internal const int SigTermExitCode = 143;
+
+ private static readonly Lock Gate = new();
+ private static readonly HashSet ActiveScopes = [];
+ private static IDisposable? s_cancelKeyRegistration;
+ private static PosixSignalRegistration? s_sigTermRegistration;
+ private static ClaimedSignal? s_claimedSignal;
+ private static int s_generation;
+ private static int s_pendingDrainCount;
+ private static bool s_registrationsInitialized;
+ private static RegistrationFault? s_registrationFaultForTesting;
+
+ ///
+ /// Gives a test a coordinator with no installed registrations, optionally failing the next
+ /// registration attempt, and leaves it able to install fresh ones on disposal.
+ ///
+ /// Registrations capture the generation counter they were created under, so putting a saved
+ /// registration object back after the counter has moved would leave it permanently stale and
+ /// silently disable the bridge for the rest of the process. Both entering and leaving therefore
+ /// tear the registrations down and let the next scope install new ones.
+ ///
+ ///
+ internal static IDisposable IsolateRegistrationsForTesting(
+ Exception? registrationFault = null,
+ bool faultAfterSigTermRegistration = false) =>
+ new RegistrationIsolationScope(
+ registrationFault is null
+ ? null
+ : new RegistrationFault(registrationFault, faultAfterSigTermRegistration));
+
+ internal static void Register(ProcessSignalCancellationScope scope)
+ {
+ ArgumentNullException.ThrowIfNull(scope);
+ RegistrationOutcome outcome;
+ Action? startCancellation = null;
+ lock (Gate)
+ {
+ outcome = TryInitializeRegistrations();
+ // The scope joins the epoch even when no bridge could be installed, so that disposal stays
+ // symmetric and a run started before an earlier scope claimed a signal still inherits it.
+ ActiveScopes.Add(scope);
+ if (s_claimedSignal is { } claimedSignal)
+ {
+ startCancellation = scope.PrepareSignalCancellation(claimedSignal.ExitCode);
+ }
+ }
+
+ // Installing the bridge is a convenience, not a precondition for running the command. Every way
+ // it can fail to install — an unsupported platform, or an environment that refuses the
+ // registration — degrades to caller-owned handling and says so once, on the same path.
+ if (outcome.Diagnostic is { } diagnostic)
+ {
+ outcome.OrphanedCancelKeyRegistration?.Dispose();
+ outcome.OrphanedSigTermRegistration?.Dispose();
+ WriteDiagnostic(diagnostic);
+ }
+
+ startCancellation?.Invoke();
+ }
+
+ private static RegistrationOutcome TryInitializeRegistrations()
+ {
+ if (s_registrationsInitialized)
+ {
+ return default;
+ }
+
+ if (!IsSignalBridgeSupported())
+ {
+ s_registrationsInitialized = true;
+ return new RegistrationOutcome(
+ "Automatic process-signal handling is unavailable on this platform; "
+ + "the caller or platform host remains responsible for cancellation.",
+ OrphanedCancelKeyRegistration: null,
+ OrphanedSigTermRegistration: null);
+ }
+
+ PosixSignalRegistration? sigTermRegistration = null;
+ IDisposable? cancelKeyRegistration = null;
+ var generation = ++s_generation;
+ try
+ {
+ // No supported platform rejects a signal registration on demand, so the failure policy
+ // would otherwise be untestable. Tests set this to drive Register through the failing path,
+ // either before anything is registered or after SIGTERM is, which is the only way to reach
+ // the orphaned-registration cleanup below.
+ ThrowIfFaultInjected(afterSigTermRegistration: false);
+
+ if (!OperatingSystem.IsWindows())
+ {
+ sigTermRegistration = PosixSignalRegistration.Create(
+ PosixSignal.SIGTERM,
+ e => HandleSigTerm(generation, e));
+ }
+
+ ThrowIfFaultInjected(afterSigTermRegistration: true);
+
+ cancelKeyRegistration = ConsoleCancelKeyCoordinator.RegisterStandalone(
+ () => HandleSigInt(generation));
+ s_sigTermRegistration = sigTermRegistration;
+ s_cancelKeyRegistration = cancelKeyRegistration;
+ s_registrationsInitialized = true;
+ return default;
+ }
+ catch (Exception ex)
+ {
+ // Invalidate callbacks created by the failed generation before releasing the gate.
+ s_generation++;
+ // Latch the attempt: without this every later run repeats a registration the environment
+ // has already refused, and emits the same diagnostic once per run.
+ s_registrationsInitialized = true;
+ return new RegistrationOutcome(
+ "Failed to install automatic process-signal handling: "
+ + $"{ex.GetType().Name}: {ex.Message}. "
+ + "The caller or platform host remains responsible for cancellation.",
+ cancelKeyRegistration,
+ sigTermRegistration);
+ }
+ }
+
+ private static void ThrowIfFaultInjected(bool afterSigTermRegistration)
+ {
+ if (s_registrationFaultForTesting is { } fault
+ && fault.AfterSigTermRegistration == afterSigTermRegistration)
+ {
+ throw fault.Exception;
+ }
+ }
+
+ internal static async Task UnregisterAsync(ProcessSignalCancellationScope scope)
+ {
+ ArgumentNullException.ThrowIfNull(scope);
+ Task cancellationTask;
+ lock (Gate)
+ {
+ cancellationTask = scope.MarkDisposedAndGetCancellationTaskAsync();
+ ActiveScopes.Remove(scope);
+ s_pendingDrainCount++;
+ }
+
+ Exception? cancellationCallbackException = null;
+ try
+ {
+#pragma warning disable VSTHRD003 // Signal-triggered callbacks must drain before their epoch can reset.
+ await cancellationTask.ConfigureAwait(false);
+#pragma warning restore VSTHRD003
+ }
+ catch (Exception ex)
+ {
+ // This task exclusively represents CancellationToken callbacks reserved by the scope.
+ cancellationCallbackException = ex;
+ }
+ finally
+ {
+ lock (Gate)
+ {
+ s_pendingDrainCount--;
+ if (ActiveScopes.Count == 0 && s_pendingDrainCount == 0)
+ {
+ s_claimedSignal = null;
+ }
+ }
+ }
+
+ return cancellationCallbackException;
+ }
+
+ private static ConsoleCancelKeyHandlingResult HandleSigInt(int generation) =>
+ TryClaimSignal(generation, "SIGINT", SigIntExitCode);
+
+ private static void HandleSigTerm(int generation, PosixSignalContext e)
+ {
+ if (TryClaimSignal(generation, "SIGTERM", SigTermExitCode)
+ == ConsoleCancelKeyHandlingResult.SuppressProcessTermination)
+ {
+ e.Cancel = true;
+ }
+ }
+
+ private static ConsoleCancelKeyHandlingResult TryClaimSignal(
+ int generation,
+ string name,
+ int exitCode)
+ {
+ ClaimedSignal? previousSignal;
+ List? startCancellations = null;
+ lock (Gate)
+ {
+ if (generation != s_generation)
+ {
+ return ConsoleCancelKeyHandlingResult.NotHandled;
+ }
+
+ previousSignal = s_claimedSignal;
+ if (ActiveScopes.Count == 0 && previousSignal is null)
+ {
+ return ConsoleCancelKeyHandlingResult.NotHandled;
+ }
+
+ if (previousSignal is null)
+ {
+ s_claimedSignal = new ClaimedSignal(name, exitCode);
+ startCancellations = [];
+ foreach (var scope in ActiveScopes)
+ {
+ if (scope.PrepareSignalCancellation(exitCode) is { } startCancellation)
+ {
+ startCancellations.Add(startCancellation);
+ }
+ }
+ }
+ }
+
+ if (previousSignal is { } claimedSignal)
+ {
+ WriteDiagnostic(
+ $"Received {name} after {claimedSignal.Name}; allowing immediate operating-system termination.");
+ return ConsoleCancelKeyHandlingResult.AllowProcessTermination;
+ }
+
+ if (startCancellations is not { } cancellations)
+ {
+ return ConsoleCancelKeyHandlingResult.NotHandled;
+ }
+
+ foreach (var startCancellation in cancellations)
+ {
+ startCancellation();
+ }
+
+ WriteDiagnostic(
+ $"Received {name}; cancelling active standalone runs. Send the signal again to terminate immediately.");
+ return ConsoleCancelKeyHandlingResult.SuppressProcessTermination;
+ }
+
+ private static bool IsSignalBridgeSupported() =>
+ IsSignalBridgeSupportedForTesting(
+ OperatingSystem.IsAndroid(),
+ OperatingSystem.IsBrowser(),
+ OperatingSystem.IsIOS(),
+ OperatingSystem.IsTvOS());
+
+ internal static bool IsSignalBridgeSupportedForTesting(
+ bool isAndroid,
+ bool isBrowser,
+ bool isIOS,
+ bool isTvOS) =>
+ !isAndroid
+ && !isBrowser
+ && !isIOS
+ && !isTvOS;
+
+ internal static void WriteDiagnostic(string message)
+ {
+ try
+ {
+#pragma warning disable MA0045 // Process-signal callbacks must decide synchronously before the OS resumes default handling.
+ ReplSessionIO.Error.WriteLine(message);
+#pragma warning restore MA0045
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException)
+ {
+ // Signal delivery must not fail merely because the diagnostic stream is unavailable.
+ }
+ }
+
+ private sealed class RegistrationIsolationScope : IDisposable
+ {
+ public RegistrationIsolationScope(RegistrationFault? registrationFault) =>
+ TearDownRegistrations(registrationFault);
+
+ public void Dispose() => TearDownRegistrations(registrationFault: null);
+
+ private static void TearDownRegistrations(RegistrationFault? registrationFault)
+ {
+ IDisposable? cancelKeyRegistration;
+ PosixSignalRegistration? sigTermRegistration;
+ lock (Gate)
+ {
+ cancelKeyRegistration = s_cancelKeyRegistration;
+ sigTermRegistration = s_sigTermRegistration;
+ s_cancelKeyRegistration = null;
+ s_sigTermRegistration = null;
+ // Uninstalled, so the next Register installs fresh registrations under a current
+ // generation instead of reviving ones the counter has already left behind.
+ s_registrationsInitialized = false;
+ s_generation++;
+ s_registrationFaultForTesting = registrationFault;
+ }
+
+ cancelKeyRegistration?.Dispose();
+ sigTermRegistration?.Dispose();
+ }
+ }
+
+ private readonly record struct ClaimedSignal(string Name, int ExitCode);
+
+ ///
+ /// The result of one registration attempt. A null means the bridge is
+ /// installed, or was already. Otherwise the bridge is not installed, the caller owns signals, and any
+ /// registration created before the attempt failed is handed back for disposal outside the gate.
+ ///
+ private readonly record struct RegistrationOutcome(
+ string? Diagnostic,
+ IDisposable? OrphanedCancelKeyRegistration,
+ PosixSignalRegistration? OrphanedSigTermRegistration);
+
+ private readonly record struct RegistrationFault(
+ Exception Exception,
+ bool AfterSigTermRegistration);
+}
diff --git a/src/Repl.Defaults/ProcessSignalHandlingMode.cs b/src/Repl.Defaults/ProcessSignalHandlingMode.cs
new file mode 100644
index 0000000..018d40d
--- /dev/null
+++ b/src/Repl.Defaults/ProcessSignalHandlingMode.cs
@@ -0,0 +1,24 @@
+namespace Repl;
+
+///
+/// Controls whether standalone runs translate process termination signals into cooperative cancellation.
+///
+public enum ProcessSignalHandlingMode
+{
+ ///
+ /// Process signal handling remains the responsibility of the caller. This is the zero value so that
+ /// an unset configuration field or a zero-initialized value agrees with the caller-owned application
+ /// default instead of silently claiming process-wide signal ownership.
+ ///
+ None = 0,
+
+ ///
+ /// Standalone runs handle Ctrl+C console events, plus Ctrl+Break on Windows, for their duration.
+ /// They also handle SIGTERM on supported Unix platforms. The first such signal cancels the handler's
+ /// token so cleanup can run, and the run then reports 130 for Ctrl+C or Ctrl+Break and
+ /// 143 for SIGTERM, unless the handler returned a non-zero exit code of its own, which wins.
+ /// A second signal is left to the operating system. Interactive sessions are unaffected: inside an
+ /// interactive command, Ctrl+C keeps cancelling only that command.
+ ///
+ Automatic = 1,
+}
diff --git a/src/Repl.Defaults/README.md b/src/Repl.Defaults/README.md
index c26bb49..354c774 100644
--- a/src/Repl.Defaults/README.md
+++ b/src/Repl.Defaults/README.md
@@ -27,6 +27,10 @@ app.Map("hello", () => "world");
return app.Run(args);
```
+## Process signals
+
+Process-owning profiles cooperatively translate a first Ctrl+C event, Ctrl+Break on Windows, or SIGTERM on supported Unix platforms into handler cancellation. An unprofiled app and embedded hosts remain caller-owned by default; Repl does not claim their standalone signals. Configure this per run with `ReplRunOptions.ProcessSignalHandling`; see the [configuration reference](https://repl.yllibed.org/reference/configuration/#process-signal-handling) for exit codes `130`/`143`, second-signal escalation, token lifetime, and platform limits.
+
## Docs
- [REPL Mode](https://repl.yllibed.org/getting-started/repl-mode/) — interactive session, scopes, history
diff --git a/src/Repl.Defaults/ReplApp.cs b/src/Repl.Defaults/ReplApp.cs
index 0865b82..007ccd2 100644
--- a/src/Repl.Defaults/ReplApp.cs
+++ b/src/Repl.Defaults/ReplApp.cs
@@ -20,6 +20,7 @@ public sealed class ReplApp : IReplApp
// Ensures modules resolved via DI share the same service instances
// as handler parameters resolved at runtime.
private ServiceProvider? _sharedProvider;
+ private ProcessSignalHandlingMode _defaultProcessSignalHandling = ProcessSignalHandlingMode.None;
// Extension packages (e.g. Repl.Spectre) park per-app configuration here so it stays
// reachable even when the shared provider was materialized before the Use* call —
@@ -28,6 +29,9 @@ public sealed class ReplApp : IReplApp
internal IServiceCollection ServiceDescriptors => _services;
+ internal void SetDefaultProcessSignalHandling(ProcessSignalHandlingMode mode) =>
+ _defaultProcessSignalHandling = mode;
+
internal void SetExtensionState(T value) where T : class => _extensionState[typeof(T)] = value;
internal T? GetExtensionState() where T : class =>
@@ -207,38 +211,75 @@ public ReplApp MapModule(IReplModule module, Delegate isPresent)
}
///
- /// Runs using internally configured services.
+ /// Runs using internally configured services and owns process signals according to .
///
public int Run(string[] args, ReplRunOptions? options = null)
{
ArgumentNullException.ThrowIfNull(args);
- var provider = EnsureSharedProvider();
#pragma warning disable VSTHRD002
- return RunAsync(args, provider, options).AsTask().GetAwaiter().GetResult();
+ return RunAsync(args, options).AsTask().GetAwaiter().GetResult();
#pragma warning restore VSTHRD002
}
///
- /// Runs using internally configured services.
+ /// Runs using internally configured services and owns process signals according to .
///
+ /// Command-line arguments.
+ /// Per-run options. A null signal-handling value inherits the active profile.
+ /// Caller-owned cancellation token. Automatic signal mode injects a linked, run-scoped token; caller-owned mode passes this token through directly.
public async ValueTask RunAsync(
string[] args,
ReplRunOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(args);
- var provider = EnsureSharedProvider();
- return await RunAsync(args, provider, options, cancellationToken).ConfigureAwait(false);
+ var runOptions = options ?? new ReplRunOptions();
+ var processSignalHandling = options?.ProcessSignalHandling ?? _defaultProcessSignalHandling;
+ if (processSignalHandling == ProcessSignalHandlingMode.None)
+ {
+ var provider = EnsureSharedProvider();
+ return await RunWithServicesAsync(args, provider, runOptions, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ var signals = new ProcessSignalCancellationScope(cancellationToken);
+ var runExitCode = 0;
+ OperationCanceledException? cancellationException = null;
+ try
+ {
+ var provider = EnsureSharedProvider();
+ runExitCode = await RunWithServicesAsync(args, provider, runOptions, signals.Token)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException ex)
+ {
+ cancellationException = ex;
+ }
+ finally
+ {
+ await signals.DisposeAsync().ConfigureAwait(false);
+ }
+
+ if (cancellationException is not null && signals.ExitCode is null)
+ {
+ // Scope disposal must finish before deciding whether cancellation came from a claimed
+ // process signal; ExceptionDispatchInfo preserves the original cancellation stack.
+ System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(cancellationException).Throw();
+ }
+
+ return signals.ResolveExitCode(runExitCode);
}
///
- /// Runs using internally configured services.
+ /// Runs using internally configured services and owns process signals according to .
///
+ /// Command-line arguments.
+ /// Caller-owned cancellation token. Automatic signal mode injects a linked, run-scoped token; caller-owned mode passes this token through directly.
public ValueTask RunAsync(string[] args, CancellationToken cancellationToken) =>
RunAsync(args, options: null, cancellationToken);
///
- /// Runs using an externally managed service provider.
+ /// Runs using an externally managed service provider; standalone signal bridging and cancellation remain caller-owned. Interactive mode retains its own Ctrl+C policy.
///
public int Run(string[] args, IServiceProvider services, ReplRunOptions? options = null)
{
@@ -250,7 +291,7 @@ public int Run(string[] args, IServiceProvider services, ReplRunOptions? options
}
///
- /// Runs using an externally managed host.
+ /// Runs using an externally managed host; standalone signal bridging and cancellation remain caller-owned. Interactive mode retains its own Ctrl+C policy.
///
public int Run(string[] args, IHost host, ReplRunOptions? options = null)
{
@@ -262,8 +303,12 @@ public int Run(string[] args, IHost host, ReplRunOptions? options = null)
}
///
- /// Runs using an externally managed service provider.
+ /// Runs using an externally managed service provider; standalone signal bridging and cancellation remain caller-owned. Interactive mode retains its own Ctrl+C policy.
///
+ /// Command-line arguments.
+ /// Caller-owned service provider.
+ /// Per-run options. An explicit request is diagnosed and ignored because this overload is externally owned.
+ /// Caller-owned cancellation token. This overload does not install a standalone process-signal bridge or create a signal-linked token.
public async ValueTask RunAsync(
string[] args,
IServiceProvider services,
@@ -273,6 +318,17 @@ public async ValueTask RunAsync(
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(services);
var runOptions = options ?? new ReplRunOptions();
+ DiagnoseIgnoredProcessSignalHandling(runOptions);
+ return await RunWithServicesAsync(args, services, runOptions, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ private async ValueTask RunWithServicesAsync(
+ string[] args,
+ IServiceProvider services,
+ ReplRunOptions runOptions,
+ CancellationToken cancellationToken)
+ {
if (runOptions.HostedServiceLifecycle is HostedServiceLifecycleMode.None or HostedServiceLifecycleMode.Guest)
{
return await _core.RunWithServicesAsync(args, services, cancellationToken).ConfigureAwait(false);
@@ -311,8 +367,12 @@ await HostedServiceLifecycleCoordinator.StopAsync(started, CancellationToken.Non
}
///
- /// Runs using an externally managed host.
+ /// Runs using an externally managed host; standalone signal bridging and cancellation remain caller-owned. Interactive mode retains its own Ctrl+C policy.
///
+ /// Command-line arguments.
+ /// Caller-owned application host.
+ /// Per-run options. An explicit request is diagnosed and ignored because this overload is externally owned.
+ /// Caller-owned cancellation token. This overload does not install a standalone process-signal bridge or create a signal-linked token.
public ValueTask RunAsync(
string[] args,
IHost host,
@@ -325,7 +385,7 @@ public ValueTask RunAsync(
}
///
- /// Runs against an externally managed input/output host.
+ /// Runs against an externally managed input/output host; standalone signal bridging and cancellation remain caller-owned. Interactive mode retains its own Ctrl+C policy.
///
public int Run(string[] args, IReplHost host, ReplRunOptions? options = null)
{
@@ -337,8 +397,12 @@ public int Run(string[] args, IReplHost host, ReplRunOptions? options = null)
}
///
- /// Runs against an externally managed input/output host.
+ /// Runs against an externally managed input/output host; standalone signal bridging and cancellation remain caller-owned. Interactive mode retains its own Ctrl+C policy.
///
+ /// Command-line arguments.
+ /// Caller-owned input/output host.
+ /// Per-run options. An explicit request is diagnosed and ignored because this overload is externally owned.
+ /// Caller-owned cancellation token. This overload does not install a standalone process-signal bridge or create a signal-linked token.
public async ValueTask RunAsync(
string[] args,
IReplHost host,
@@ -353,12 +417,13 @@ public async ValueTask RunAsync(
using (ReplSessionIO.SetSession(host.Output, host.Input, runOptions.AnsiSupport, sessionHost?.SessionId))
{
ApplyTerminalOverrides(runOptions);
- return await RunAsync(args, runOptions, cancellationToken).ConfigureAwait(false);
+ var provider = EnsureSharedProvider();
+ return await RunAsync(args, provider, runOptions, cancellationToken).ConfigureAwait(false);
}
}
///
- /// Runs against an externally managed input/output host with an external service provider.
+ /// Runs against an externally managed input/output host with an external service provider; standalone signal bridging and cancellation remain caller-owned. Interactive mode retains its own Ctrl+C policy.
///
public int Run(string[] args, IReplHost host, IServiceProvider services, ReplRunOptions? options = null)
{
@@ -371,8 +436,13 @@ public int Run(string[] args, IReplHost host, IServiceProvider services, ReplRun
}
///
- /// Runs against an externally managed input/output host with an external service provider.
+ /// Runs against an externally managed input/output host with an external service provider; standalone signal bridging and cancellation remain caller-owned. Interactive mode retains its own Ctrl+C policy.
///
+ /// Command-line arguments.
+ /// Caller-owned input/output host.
+ /// Caller-owned service provider.
+ /// Per-run options. An explicit request is diagnosed and ignored because this overload is externally owned.
+ /// Caller-owned cancellation token. This overload does not install a standalone process-signal bridge or create a signal-linked token.
public async ValueTask RunAsync(
string[] args,
IReplHost host,
@@ -388,6 +458,7 @@ public async ValueTask RunAsync(
var sessionHost = host as IReplSessionHost;
using (ReplSessionIO.SetSession(host.Output, host.Input, runOptions.AnsiSupport, sessionHost?.SessionId))
{
+ DiagnoseIgnoredProcessSignalHandling(runOptions);
ApplyTerminalOverrides(runOptions);
var sessionProvider = CreateSessionOverlay(services);
return await _core.RunWithServicesAsync(args, sessionProvider, cancellationToken)
@@ -395,6 +466,16 @@ public async ValueTask RunAsync(
}
}
+ private static void DiagnoseIgnoredProcessSignalHandling(ReplRunOptions runOptions)
+ {
+ if (runOptions.ProcessSignalHandling == ProcessSignalHandlingMode.Automatic)
+ {
+ ProcessSignalCoordinator.WriteDiagnostic(
+ "Ignoring ReplRunOptions.ProcessSignalHandling=Automatic because this overload "
+ + "uses an externally managed host or service provider; the caller owns process signals.");
+ }
+ }
+
private static void ApplyTerminalOverrides(ReplRunOptions runOptions)
{
var overrides = runOptions.TerminalOverrides;
diff --git a/src/Repl.Defaults/ReplAppProfileExtensions.cs b/src/Repl.Defaults/ReplAppProfileExtensions.cs
index a6fb56c..52fb1bd 100644
--- a/src/Repl.Defaults/ReplAppProfileExtensions.cs
+++ b/src/Repl.Defaults/ReplAppProfileExtensions.cs
@@ -6,7 +6,11 @@ namespace Repl;
public static class ReplAppProfileExtensions
{
///
- /// Applies interactive defaults for console usage.
+ /// Applies interactive defaults for console usage. This profile also takes process signal ownership
+ /// for standalone runs — see for exactly what that
+ /// means, including why an interactive session's own Ctrl+C behavior is unchanged. Set
+ /// to
+ /// to keep that ownership with the caller for one run.
///
/// Target app.
/// The same app instance.
@@ -19,12 +23,16 @@ public static ReplApp UseDefaultInteractive(this ReplApp app)
options.Interactive.Prompt = ">";
options.Interactive.InteractivePolicy = InteractivePolicy.Auto;
});
+ app.SetDefaultProcessSignalHandling(ProcessSignalHandlingMode.Automatic);
return app;
}
///
- /// Applies defaults suited for CLI one-shot execution.
+ /// Applies process-owning defaults suited for CLI one-shot execution. This profile takes process signal
+ /// ownership for standalone runs — see for exactly
+ /// what that means. Set to
+ /// to keep that ownership with the caller for one run.
///
/// Target app.
/// The same app instance.
@@ -38,12 +46,13 @@ public static ReplApp UseCliProfile(this ReplApp app)
options.Output.DefaultFormat = "human";
options.Output.BannerEnabled = true;
});
+ app.SetDefaultProcessSignalHandling(ProcessSignalHandlingMode.Automatic);
return app;
}
///
- /// Applies defaults suited for embedded host scenarios.
+ /// Applies defaults suited for embedded host scenarios, leaving process signal ownership with the caller.
///
/// Target app.
/// The same app instance.
@@ -56,6 +65,7 @@ public static ReplApp UseEmbeddedConsoleProfile(this ReplApp app)
options.AmbientCommands.ExitCommandEnabled = false;
options.Interactive.InteractivePolicy = InteractivePolicy.Auto;
});
+ app.SetDefaultProcessSignalHandling(ProcessSignalHandlingMode.None);
return app;
}
diff --git a/src/Repl.Defaults/ReplRunOptions.cs b/src/Repl.Defaults/ReplRunOptions.cs
index c54fa81..432a995 100644
--- a/src/Repl.Defaults/ReplRunOptions.cs
+++ b/src/Repl.Defaults/ReplRunOptions.cs
@@ -5,6 +5,12 @@ namespace Repl;
///
public sealed record ReplRunOptions
{
+ ///
+ /// Gets how standalone runs handle process termination signals.
+ /// uses the active application profile's default.
+ ///
+ public ProcessSignalHandlingMode? ProcessSignalHandling { get; init; }
+
///
/// Gets or sets the hosted-service lifecycle behavior.
///
diff --git a/src/Repl.IntegrationTests/Given_ProcessSignals.cs b/src/Repl.IntegrationTests/Given_ProcessSignals.cs
new file mode 100644
index 0000000..074d543
--- /dev/null
+++ b/src/Repl.IntegrationTests/Given_ProcessSignals.cs
@@ -0,0 +1,258 @@
+using System.Diagnostics;
+using AwesomeAssertions;
+
+namespace Repl.IntegrationTests;
+
+[TestClass]
+[OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)]
+public sealed class Given_ProcessSignals
+{
+ private const int SigInt = 2;
+ private const int SigQuit = 3;
+ private const int SigTerm = 15;
+ private const int SigIntExitCode = 130;
+ private const int SigQuitExitCode = 131;
+ private const int SigTermExitCode = 143;
+ private const int CleanupDelayMilliseconds = 30_000;
+ private const string CleanupCompletedMarker = "CLEANUP-COMPLETED";
+ private static readonly TimeSpan ProcessTimeout = TimeSpan.FromSeconds(15);
+ private static readonly TimeSpan ForcedTerminationMaximum = TimeSpan.FromSeconds(10);
+
+ [TestMethod]
+ [DataRow(SigInt, SigIntExitCode, "SIGINT", false, DisplayName = "RunAsync: SIGINT cancels cooperatively and exits 130")]
+ [DataRow(SigTerm, SigTermExitCode, "SIGTERM", false, DisplayName = "RunAsync: SIGTERM cancels cooperatively and exits 143")]
+ [DataRow(SigInt, SigIntExitCode, "SIGINT", true, DisplayName = "Run: SIGINT cancels cooperatively and exits 130")]
+ [DataRow(SigTerm, SigTermExitCode, "SIGTERM", true, DisplayName = "Run: SIGTERM cancels cooperatively and exits 143")]
+ [Description("A standalone one-shot run converts process signals into cooperative cancellation before exiting.")]
+ public async Task When_StandaloneRunReceivesSignal_Then_FinallyRunsAndConventionalExitCodeIsReturned(
+ int signal,
+ int expectedExitCode,
+ string expectedSignalName,
+ bool useSynchronousRun)
+ {
+ var marker = Path.Combine(Path.GetTempPath(), $"repl-signal-{Guid.NewGuid():N}.txt");
+ using var process = ShellCompletionTestHostRunner.Start(
+ "process-signal",
+ ["wait", marker, "--no-logo"],
+ out var readOutput,
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["REPL_TEST_USE_SYNC_RUN"] = useSynchronousRun.ToString(
+ System.Globalization.CultureInfo.InvariantCulture),
+ });
+ try
+ {
+ await WaitForMarkerAsync(process, marker, "READY", readOutput).ConfigureAwait(false);
+
+ await SendSignalAsync(process, signal).ConfigureAwait(false);
+ await WaitForExitAsync(process, readOutput).ConfigureAwait(false);
+
+ process.ExitCode.Should().Be(expectedExitCode);
+ (await File.ReadAllLinesAsync(marker).ConfigureAwait(false)).Should().Equal("READY", "FINALLY");
+ readOutput().Should().Contain(
+ $"Received {expectedSignalName}; cancelling active standalone runs.");
+ }
+ finally
+ {
+ await TerminateIfRunningAsync(process).ConfigureAwait(false);
+ File.Delete(marker);
+ }
+ }
+
+ [TestMethod]
+ [Description("ProcessSignalHandlingMode.None leaves SIGTERM and cleanup ownership with the process host.")]
+ public async Task When_ProcessSignalHandlingIsNone_Then_SigTermUsesOperatingSystemDefault()
+ {
+ var marker = Path.Combine(Path.GetTempPath(), $"repl-signal-{Guid.NewGuid():N}.txt");
+ using var process = ShellCompletionTestHostRunner.Start(
+ "process-signal",
+ ["wait", marker, "--no-logo"],
+ out var readOutput,
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["REPL_TEST_SIGNAL_HANDLING"] = nameof(ProcessSignalHandlingMode.None),
+ });
+ try
+ {
+ await WaitForMarkerAsync(process, marker, "READY", readOutput).ConfigureAwait(false);
+
+ await SendSignalAsync(process, SigTerm).ConfigureAwait(false);
+ await WaitForExitAsync(process, readOutput).ConfigureAwait(false);
+
+ process.ExitCode.Should().Be(SigTermExitCode);
+ (await File.ReadAllLinesAsync(marker).ConfigureAwait(false)).Should().Equal("READY");
+ readOutput().Should().NotContain("Received SIGTERM");
+ }
+ finally
+ {
+ await TerminateIfRunningAsync(process).ConfigureAwait(false);
+ File.Delete(marker);
+ }
+ }
+
+ [TestMethod]
+ [Description("Automatic handling leaves Unix SIGQUIT to the operating system instead of reinterpreting ControlBreak as SIGINT.")]
+ public async Task When_AutomaticRunReceivesSigQuit_Then_OperatingSystemTerminatesProcess()
+ {
+ var marker = Path.Combine(Path.GetTempPath(), $"repl-signal-{Guid.NewGuid():N}.txt");
+ using var process = ShellCompletionTestHostRunner.Start(
+ "process-signal",
+ ["wait", marker, "--no-logo"],
+ out var readOutput);
+ try
+ {
+ await WaitForMarkerAsync(process, marker, "READY", readOutput).ConfigureAwait(false);
+
+ await SendSignalAsync(process, SigQuit).ConfigureAwait(false);
+ await WaitForExitAsync(process, readOutput).ConfigureAwait(false);
+
+ process.ExitCode.Should().Be(SigQuitExitCode);
+ (await File.ReadAllLinesAsync(marker).ConfigureAwait(false)).Should().Equal("READY");
+ readOutput().Should().NotContain("Received SIGINT");
+ }
+ finally
+ {
+ await TerminateIfRunningAsync(process).ConfigureAwait(false);
+ File.Delete(marker);
+ }
+ }
+
+ [TestMethod]
+ [Description("A handler's explicit non-zero exit code remains authoritative after cooperative signal cancellation.")]
+ public async Task When_HandlerReturnsExplicitFailureAfterSignal_Then_HandlerExitCodeIsPreserved()
+ {
+ var marker = Path.Combine(Path.GetTempPath(), $"repl-signal-{Guid.NewGuid():N}.txt");
+ using var process = ShellCompletionTestHostRunner.Start(
+ "process-signal-exit-code",
+ ["wait", marker, "--no-logo"],
+ out var readOutput);
+ try
+ {
+ await WaitForMarkerAsync(process, marker, "READY", readOutput).ConfigureAwait(false);
+
+ await SendSignalAsync(process, SigTerm).ConfigureAwait(false);
+ await WaitForExitAsync(process, readOutput).ConfigureAwait(false);
+
+ process.ExitCode.Should().Be(7);
+ (await File.ReadAllLinesAsync(marker).ConfigureAwait(false))
+ .Should().Equal("READY", "HANDLER-RETURNED");
+ }
+ finally
+ {
+ await TerminateIfRunningAsync(process).ConfigureAwait(false);
+ File.Delete(marker);
+ }
+ }
+
+ [TestMethod]
+ [Description("A second SIGTERM during cooperative cleanup promptly falls through to the operating system.")]
+ public async Task When_SecondSigTermArrivesDuringCleanup_Then_OperatingSystemTerminatesProcess()
+ {
+ var marker = Path.Combine(Path.GetTempPath(), $"repl-signal-{Guid.NewGuid():N}.txt");
+ using var process = ShellCompletionTestHostRunner.Start(
+ "process-signal",
+ ["wait", marker, "--no-logo"],
+ out var readOutput,
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["REPL_TEST_SIGNAL_CLEANUP_DELAY_MS"] = CleanupDelayMilliseconds.ToString(
+ System.Globalization.CultureInfo.InvariantCulture),
+ });
+ try
+ {
+ await WaitForMarkerAsync(process, marker, "READY", readOutput).ConfigureAwait(false);
+ await SendSignalAsync(process, SigTerm).ConfigureAwait(false);
+ await WaitForMarkerAsync(process, marker, "FINALLY", readOutput).ConfigureAwait(false);
+
+ var forcedTermination = Stopwatch.StartNew();
+ await SendSignalAsync(process, SigTerm).ConfigureAwait(false);
+ await WaitForExitAsync(process, readOutput).ConfigureAwait(false);
+ forcedTermination.Stop();
+
+ process.ExitCode.Should().Be(SigTermExitCode);
+ forcedTermination.Elapsed.Should().BeLessThan(ForcedTerminationMaximum);
+ var markers = await File.ReadAllLinesAsync(marker).ConfigureAwait(false);
+ markers.Should().Equal("READY", "FINALLY");
+ markers.Should().NotContain(CleanupCompletedMarker);
+ // The escalation diagnostic is documented operator-facing behavior, so pin its text: an
+ // operator greps it to tell a forced termination from a crash.
+ readOutput().Should().Contain("allowing immediate operating-system termination");
+ }
+ finally
+ {
+ await TerminateIfRunningAsync(process).ConfigureAwait(false);
+ File.Delete(marker);
+ }
+ }
+
+ private static async Task WaitForMarkerAsync(
+ Process process,
+ string path,
+ string marker,
+ Func readOutput)
+ {
+ var deadline = DateTime.UtcNow + ProcessTimeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ if (File.Exists(path)
+ && (await File.ReadAllLinesAsync(path).ConfigureAwait(false)).Contains(marker, StringComparer.Ordinal))
+ {
+ return;
+ }
+
+ if (process.HasExited)
+ {
+ throw new InvalidOperationException(
+ $"Signal test host exited with code {process.ExitCode} before writing {marker}."
+ + $"{Environment.NewLine}Captured output:{Environment.NewLine}{readOutput()}");
+ }
+
+ await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false);
+ }
+
+ throw new TimeoutException(
+ $"Signal test host did not write {marker} within {ProcessTimeout}."
+ + $"{Environment.NewLine}Captured output:{Environment.NewLine}{readOutput()}");
+ }
+
+ private static async Task WaitForExitAsync(Process process, Func readOutput)
+ {
+ try
+ {
+ await process.WaitForExitAsync().WaitAsync(ProcessTimeout).ConfigureAwait(false);
+ process.WaitForExit();
+ }
+ catch (TimeoutException ex)
+ {
+ throw new TimeoutException(
+ $"Signal test host did not exit within {ProcessTimeout}."
+ + $"{Environment.NewLine}Captured output:{Environment.NewLine}{readOutput()}",
+ ex);
+ }
+ }
+
+ private static async Task SendSignalAsync(Process target, int signal)
+ {
+ var startInfo = new ProcessStartInfo("kill")
+ {
+ UseShellExecute = false,
+ RedirectStandardError = true,
+ };
+ startInfo.ArgumentList.Add($"-{signal}");
+ startInfo.ArgumentList.Add(target.Id.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ using var sender = Process.Start(startInfo)
+ ?? throw new InvalidOperationException("Failed to start the signal sender.");
+ await sender.WaitForExitAsync().WaitAsync(ProcessTimeout).ConfigureAwait(false);
+ var error = await sender.StandardError.ReadToEndAsync().ConfigureAwait(false);
+ sender.ExitCode.Should().Be(0, because: $"the test signal must reach the child process: {error}");
+ }
+
+ private static async Task TerminateIfRunningAsync(Process process)
+ {
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ await process.WaitForExitAsync().WaitAsync(ProcessTimeout).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/Repl.IntegrationTests/ShellCompletionTestHostRunner.cs b/src/Repl.IntegrationTests/ShellCompletionTestHostRunner.cs
index e5c5993..dcdb2bc 100644
--- a/src/Repl.IntegrationTests/ShellCompletionTestHostRunner.cs
+++ b/src/Repl.IntegrationTests/ShellCompletionTestHostRunner.cs
@@ -18,10 +18,9 @@ public static (int ExitCode, string Text) Run(
ArgumentNullException.ThrowIfNull(args);
using var process = CreateProcess(scenario, args, environment);
- var stdout = new StringBuilder();
- var stderr = new StringBuilder();
- process.OutputDataReceived += (_, eventArgs) => AppendLine(eventArgs.Data, stdout);
- process.ErrorDataReceived += (_, eventArgs) => AppendLine(eventArgs.Data, stderr);
+ var output = new ProcessOutputCapture();
+ process.OutputDataReceived += (_, eventArgs) => output.AppendOutput(eventArgs.Data);
+ process.ErrorDataReceived += (_, eventArgs) => output.AppendError(eventArgs.Data);
if (!process.Start())
{
@@ -36,10 +35,37 @@ public static (int ExitCode, string Text) Run(
}
process.StandardInput.Close();
- EnsureExitedWithinTimeout(process);
+ EnsureExitedWithinTimeout(process, output.Read);
process.WaitForExit();
- return (process.ExitCode, MergeOutput(stdout.ToString(), stderr.ToString()));
+ return (process.ExitCode, output.Read());
+ }
+
+ public static Process Start(
+ string scenario,
+ IReadOnlyList args,
+ out Func readOutput,
+ IReadOnlyDictionary? environment = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(scenario);
+ ArgumentNullException.ThrowIfNull(args);
+
+ var process = CreateProcess(scenario, args, environment);
+ var output = new ProcessOutputCapture();
+ readOutput = output.Read;
+ process.OutputDataReceived += (_, eventArgs) => output.AppendOutput(eventArgs.Data);
+ process.ErrorDataReceived += (_, eventArgs) => output.AppendError(eventArgs.Data);
+ if (process.Start())
+ {
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ process.StandardInput.Close();
+ return process;
+ }
+
+ var fileName = process.StartInfo.FileName;
+ process.Dispose();
+ throw new InvalidOperationException($"Failed to start test host process '{fileName}'.");
}
private static Process CreateProcess(
@@ -71,21 +97,6 @@ private static Process CreateProcess(
return new Process { StartInfo = startInfo };
}
- private static void AppendLine(string? line, StringBuilder builder)
- {
- if (line is null)
- {
- return;
- }
-
- if (builder.Length > 0)
- {
- builder.AppendLine();
- }
-
- builder.Append(line);
- }
-
private static string MergeOutput(string output, string error) =>
string.IsNullOrWhiteSpace(error)
? output
@@ -93,7 +104,7 @@ private static string MergeOutput(string output, string error) =>
? error
: $"{output}{Environment.NewLine}{error}";
- private static void EnsureExitedWithinTimeout(Process process)
+ private static void EnsureExitedWithinTimeout(Process process, Func readOutput)
{
if (process.WaitForExit((int)DefaultTimeout.TotalMilliseconds))
{
@@ -110,7 +121,8 @@ private static void EnsureExitedWithinTimeout(Process process)
}
throw new TimeoutException(
- $"Shell completion test host timed out after {DefaultTimeout.TotalSeconds.ToString(CultureInfo.InvariantCulture)}s.");
+ $"Shell completion test host timed out after {DefaultTimeout.TotalSeconds.ToString(CultureInfo.InvariantCulture)}s."
+ + $"{Environment.NewLine}Captured output:{Environment.NewLine}{readOutput()}");
}
private static string ResolveHostExecutablePath()
@@ -170,4 +182,41 @@ private static string ResolveBuildConfiguration()
? "Release"
: "Debug";
}
+ private sealed class ProcessOutputCapture
+ {
+ private readonly Lock _gate = new();
+ private readonly StringBuilder _output = new();
+ private readonly StringBuilder _error = new();
+
+ public void AppendOutput(string? line) => AppendLine(line, _output);
+
+ public void AppendError(string? line) => AppendLine(line, _error);
+
+ public string Read()
+ {
+ lock (_gate)
+ {
+ return MergeOutput(_output.ToString(), _error.ToString());
+ }
+ }
+
+ private void AppendLine(string? line, StringBuilder builder)
+ {
+ if (line is null)
+ {
+ return;
+ }
+
+ lock (_gate)
+ {
+ if (builder.Length > 0)
+ {
+ builder.AppendLine();
+ }
+
+ builder.Append(line);
+ }
+ }
+ }
+
}
diff --git a/src/Repl.ShellCompletionTestHost/Program.cs b/src/Repl.ShellCompletionTestHost/Program.cs
index 536be21..76026d0 100644
--- a/src/Repl.ShellCompletionTestHost/Program.cs
+++ b/src/Repl.ShellCompletionTestHost/Program.cs
@@ -6,7 +6,7 @@ namespace Repl.ShellCompletionTestHost;
internal static class Program
{
- private static int Main(string[] args)
+ private static async Task Main(string[] args)
{
var app = ReplApp.Create();
ConfigureScenario(app, Environment.GetEnvironmentVariable("REPL_TEST_SCENARIO"));
@@ -16,9 +16,18 @@ private static int Main(string[] args)
app.UseDefaultInteractive();
}
-#pragma warning disable MA0045 // Sync entry point is intentional for this test host.
- return app.Run(args);
-#pragma warning restore MA0045
+ ReplRunOptions? runOptions = null;
+ if (TryReadEnum("REPL_TEST_SIGNAL_HANDLING", out var signalHandling))
+ {
+ runOptions = new ReplRunOptions { ProcessSignalHandling = signalHandling };
+ }
+
+ if (TryReadBoolean("REPL_TEST_USE_SYNC_RUN", out var useSynchronousRun) && useSynchronousRun)
+ {
+ return app.Run(args, runOptions);
+ }
+
+ return await app.RunAsync(args, runOptions).ConfigureAwait(false);
}
private static void ConfigureScenario(ReplApp app, string? scenario)
@@ -33,12 +42,70 @@ private static void ConfigureScenario(ReplApp app, string? scenario)
case "setup":
ConfigureCompletionScenario(app);
return;
+ case "process-signal":
+ ConfigureProcessSignalScenario(app);
+ return;
+ case "process-signal-exit-code":
+ ConfigureProcessSignalExitCodeScenario(app);
+ return;
default:
throw new InvalidOperationException(
- $"Unknown REPL test scenario '{scenario}'. Supported values: completion, setup.");
+ $"Unknown REPL test scenario '{scenario}'. Supported values: completion, setup, process-signal, process-signal-exit-code.");
}
}
+ private static void ConfigureProcessSignalScenario(ReplApp app)
+ {
+ app.UseCliProfile();
+ app.Map("wait {marker}", async (string marker, CancellationToken cancellationToken) =>
+ {
+ await File.WriteAllTextAsync(marker, "READY\n", CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ await File.AppendAllTextAsync(marker, "FINALLY\n", CancellationToken.None).ConfigureAwait(false);
+ if (int.TryParse(
+ Environment.GetEnvironmentVariable("REPL_TEST_SIGNAL_CLEANUP_DELAY_MS"),
+ NumberStyles.Integer,
+ CultureInfo.InvariantCulture,
+ out var cleanupDelayMs)
+ && cleanupDelayMs > 0)
+ {
+ await Task.Delay(TimeSpan.FromMilliseconds(cleanupDelayMs), CancellationToken.None).ConfigureAwait(false);
+ await File.AppendAllTextAsync(
+ marker,
+ "CLEANUP-COMPLETED\n",
+ CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ });
+ }
+
+ private static void ConfigureProcessSignalExitCodeScenario(ReplApp app)
+ {
+ app.UseCliProfile();
+ app.Map("wait {marker}", async (string marker, CancellationToken cancellationToken) =>
+ {
+ await File.WriteAllTextAsync(marker, "READY\n", CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ await File.AppendAllTextAsync(
+ marker,
+ "HANDLER-RETURNED\n",
+ CancellationToken.None).ConfigureAwait(false);
+ }
+
+ return Results.Exit(7);
+ });
+ }
+
private static void ConfigureCompletionScenario(ReplApp app)
{
app.Map("contact list", () => "ok");
diff --git a/src/Repl.Tests/Given_CancelKeyHandler.cs b/src/Repl.Tests/Given_CancelKeyHandler.cs
index 44f7238..5f7b3f5 100644
--- a/src/Repl.Tests/Given_CancelKeyHandler.cs
+++ b/src/Repl.Tests/Given_CancelKeyHandler.cs
@@ -1,9 +1,9 @@
using AwesomeAssertions;
-using System.Reflection;
namespace Repl.Tests;
[TestClass]
+[DoNotParallelize]
public sealed class Given_CancelKeyHandler
{
[TestMethod]
@@ -33,6 +33,38 @@ public void When_CommandCtsSetToNull_Then_NoException()
handler.SetCommandCts(cts: null); // Should not throw.
}
+ [TestMethod]
+ [Description("Ctrl+Break is routed to the active interactive command on Windows.")]
+ public void When_CtrlBreakArrivesOnWindowsDuringCommand_Then_CommandIsCancelled()
+ {
+ using var handler = new CancelKeyHandler();
+ using var cancellation = new CancellationTokenSource();
+ handler.SetCommandCts(cancellation);
+
+ var result = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting(
+ ConsoleSpecialKey.ControlBreak,
+ isWindows: true);
+
+ result.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ cancellation.IsCancellationRequested.Should().BeTrue();
+ }
+
+ [TestMethod]
+ [Description("ControlBreak represents SIGQUIT on Unix and is left to the operating system.")]
+ public void When_ControlBreakArrivesOnUnixDuringCommand_Then_SigQuitIsNotClaimed()
+ {
+ using var handler = new CancelKeyHandler();
+ using var cancellation = new CancellationTokenSource();
+ handler.SetCommandCts(cancellation);
+
+ var result = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting(
+ ConsoleSpecialKey.ControlBreak,
+ isWindows: false);
+
+ result.Should().Be(ConsoleCancelKeyHandlingResult.NotHandled);
+ cancellation.IsCancellationRequested.Should().BeFalse();
+ }
+
[TestMethod]
[Description("First Ctrl+C writes the double-tap hint to ReplSessionIO.Error so protocol/session error routing remains consistent.")]
public void When_FirstCancelPressDuringCommand_Then_HintUsesSessionErrorWriter()
@@ -54,21 +86,10 @@ public void When_FirstCancelPressDuringCommand_Then_HintUsesSessionErrorWriter()
using var cts = new CancellationTokenSource();
handler.SetCommandCts(cts);
- var method = typeof(CancelKeyHandler).GetMethod(
- "OnCancelKeyPress",
- BindingFlags.Instance | BindingFlags.NonPublic);
- method.Should().NotBeNull();
- var args = (ConsoleCancelEventArgs?)Activator.CreateInstance(
- typeof(ConsoleCancelEventArgs),
- BindingFlags.Instance | BindingFlags.NonPublic,
- binder: null,
- args: [ConsoleSpecialKey.ControlC],
- culture: null);
- args.Should().NotBeNull();
- method!.Invoke(handler, [null, args]);
+ var result = handler.HandleCancelKeyForTesting();
cts.IsCancellationRequested.Should().BeTrue();
- args!.Cancel.Should().BeTrue();
+ result.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
sessionError.ToString().Should().Contain("Press Ctrl+C again to exit.");
consoleError.ToString().Should().BeEmpty();
}
diff --git a/src/Repl.Tests/Given_HandlerBinding.cs b/src/Repl.Tests/Given_HandlerBinding.cs
index 776b857..9c0acd5 100644
--- a/src/Repl.Tests/Given_HandlerBinding.cs
+++ b/src/Repl.Tests/Given_HandlerBinding.cs
@@ -1,9 +1,11 @@
using AwesomeAssertions;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
namespace Repl.Tests;
[TestClass]
+[DoNotParallelize]
public sealed class Given_HandlerBinding
{
[TestMethod]
@@ -124,13 +126,257 @@ public async Task When_BindingCancellationToken_Then_HandlerReceivesExecutionTok
return "ok";
});
+ var exitCode = await sut.RunAsync(
+ ["work"],
+ new ReplRunOptions { ProcessSignalHandling = ProcessSignalHandlingMode.None },
+ cancellationTokenSource.Token).ConfigureAwait(false);
+
+ exitCode.Should().Be(0);
+ captured.CanBeCanceled.Should().BeTrue();
+ captured.Should().Be(cancellationTokenSource.Token);
+ }
+
+ [TestMethod]
+ [Description("Embedded console profile keeps process signal ownership with its caller by default.")]
+ public async Task When_UsingEmbeddedConsoleProfile_Then_HandlerReceivesCallerTokenDirectly()
+ {
+ var sut = ReplApp.Create().UseEmbeddedConsoleProfile();
+ CancellationToken captured = default;
+ using var cancellationTokenSource = new CancellationTokenSource();
+
+ sut.Map("work", (CancellationToken ct) =>
+ {
+ captured = ct;
+ return "ok";
+ });
+
+ var exitCode = await sut.RunAsync(["work"], cancellationTokenSource.Token).ConfigureAwait(false);
+
+ exitCode.Should().Be(0);
+ captured.Should().Be(cancellationTokenSource.Token);
+ }
+
+ [TestMethod]
+ [Description("Unrelated per-run settings preserve the embedded profile's caller-owned signal default.")]
+ public async Task When_UsingEmbeddedConsoleProfileWithUnrelatedRunOptions_Then_HandlerReceivesCallerTokenDirectly()
+ {
+ var sut = ReplApp.Create().UseEmbeddedConsoleProfile();
+ CancellationToken captured = default;
+ using var cancellationTokenSource = new CancellationTokenSource();
+
+ sut.Map("work", (CancellationToken ct) =>
+ {
+ captured = ct;
+ return "ok";
+ });
+
+ var exitCode = await sut.RunAsync(
+ ["work"],
+ new ReplRunOptions { AnsiSupport = AnsiMode.Never },
+ cancellationTokenSource.Token).ConfigureAwait(false);
+
+ exitCode.Should().Be(0);
+ captured.Should().Be(cancellationTokenSource.Token);
+ }
+
+ [TestMethod]
+ [Description("An embedded console can explicitly opt into automatic process-signal ownership for one run.")]
+ public async Task When_EmbeddedConsoleExplicitlySelectsAutomatic_Then_HandlerReceivesLinkedToken()
+ {
+ var sut = ReplApp.Create().UseEmbeddedConsoleProfile();
+ CancellationToken captured = default;
+ using var cancellationTokenSource = new CancellationTokenSource();
+
+ sut.Map("work", (CancellationToken ct) =>
+ {
+ captured = ct;
+ return "ok";
+ });
+
+ var exitCode = await sut.RunAsync(
+ ["work"],
+ new ReplRunOptions { ProcessSignalHandling = ProcessSignalHandlingMode.Automatic },
+ cancellationTokenSource.Token).ConfigureAwait(false);
+
+ exitCode.Should().Be(0);
+ captured.Should().NotBe(cancellationTokenSource.Token);
+ captured.CanBeCanceled.Should().BeTrue();
+ }
+
+ [TestMethod]
+ [Description("The interactive profile takes process signal ownership, so its one-shot handlers receive a run-scoped token rather than the caller token. The ownership table lists this profile as automatic; its sibling profiles each had a test and this one did not.")]
+ public async Task When_UsingDefaultInteractiveProfile_Then_HandlerReceivesLinkedToken()
+ {
+ var sut = ReplApp.Create().UseDefaultInteractive();
+ CancellationToken captured = default;
+ using var cancellationTokenSource = new CancellationTokenSource();
+
+ sut.Map("work", (CancellationToken ct) =>
+ {
+ captured = ct;
+ return "ok";
+ });
+
var exitCode = await sut.RunAsync(["work"], cancellationTokenSource.Token).ConfigureAwait(false);
exitCode.Should().Be(0);
+ captured.Should().NotBe(cancellationTokenSource.Token);
captured.CanBeCanceled.Should().BeTrue();
+ }
+
+ [TestMethod]
+ [Description("The run-scoped token an automatic profile hands a handler is linked to the caller token, not merely a fresh one. Every other profile-ownership test asserts only that the token differs from the caller's, which a token that silently dropped caller-initiated cancellation would also satisfy.")]
+ public async Task When_CallerCancelsDuringAnAutomaticRun_Then_TheHandlerTokenObservesIt()
+ {
+ var sut = ReplApp.Create().UseCliProfile();
+ var observedCallerCancellation = false;
+ using var cancellationTokenSource = new CancellationTokenSource();
+
+ sut.Map("work", (CancellationToken ct) =>
+ {
+ cancellationTokenSource.Cancel();
+ observedCallerCancellation = ct.IsCancellationRequested;
+ return "ok";
+ });
+
+ var act = async () => await sut.RunAsync(["work", "--no-logo"], cancellationTokenSource.Token)
+ .ConfigureAwait(false);
+
+ await act.Should().ThrowAsync().ConfigureAwait(false);
+ observedCallerCancellation.Should().BeTrue();
+ }
+
+ [TestMethod]
+ [Description("An app without a process-owning profile preserves the caller-owned signal and token contract.")]
+ public async Task When_NoProfileSelectsSignalOwnership_Then_HandlerReceivesCallerTokenDirectly()
+ {
+ var sut = ReplApp.Create();
+ CancellationToken captured = default;
+ using var cancellationTokenSource = new CancellationTokenSource();
+
+ sut.Map("work", (CancellationToken ct) =>
+ {
+ captured = ct;
+ return "ok";
+ });
+
+ var exitCode = await sut.RunAsync(["work", "--no-logo"], cancellationTokenSource.Token)
+ .ConfigureAwait(false);
+
+ exitCode.Should().Be(0);
captured.Should().Be(cancellationTokenSource.Token);
}
+ [TestMethod]
+ [Description("The linked execution token used by automatic process-signal handling is disposed when its run completes.")]
+ public async Task When_AutomaticRunCompletes_Then_HandlerTokenMustNotBeRetained()
+ {
+ var sut = ReplApp.Create().UseCliProfile();
+ CancellationToken captured = default;
+ using var diagnostics = new StringWriter();
+ using var session = ReplSessionIO.SetSession(
+ TextWriter.Null,
+ TextReader.Null,
+ error: diagnostics);
+
+ sut.Map("work", (CancellationToken ct) =>
+ {
+ captured = ct;
+ return "ok";
+ });
+
+ var exitCode = await sut.RunAsync(["work", "--no-logo"]).ConfigureAwait(false);
+ var accessDisposedWaitHandle = () => _ = captured.WaitHandle;
+
+ exitCode.Should().Be(0);
+ accessDisposedWaitHandle.Should().Throw();
+ diagnostics.ToString().Should().NotContain("Ignoring ReplRunOptions.ProcessSignalHandling");
+ }
+
+ [TestMethod]
+ [Description("Regression guard: verifies automatic signal handling preserves caller-requested cancellation through the linked execution token.")]
+ public async Task When_ProcessSignalHandlingIsAutomatic_Then_HandlerObservesCallerCancellation()
+ {
+ var sut = ReplApp.Create().UseCliProfile();
+ using var cancellationTokenSource = new CancellationTokenSource();
+ var handlerStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var observedCancellation = false;
+
+ sut.Map("work", async (CancellationToken ct) =>
+ {
+ handlerStarted.SetResult();
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ observedCancellation = true;
+ throw;
+ }
+ });
+
+ var runTask = sut.RunAsync(["work"], cancellationTokenSource.Token).AsTask();
+ await handlerStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+ await cancellationTokenSource.CancelAsync().ConfigureAwait(false);
+
+#pragma warning disable VSTHRD003 // The run must start before this test requests caller cancellation.
+ var act = async () => await runTask.ConfigureAwait(false);
+#pragma warning restore VSTHRD003
+ await act.Should().ThrowAsync().ConfigureAwait(false);
+ observedCancellation.Should().BeTrue();
+ }
+
+ [TestMethod]
+ [DataRow("service-provider", DisplayName = "IServiceProvider overload")]
+ [DataRow("host", DisplayName = "IHost overload")]
+ [DataRow("repl-host", DisplayName = "IReplHost overload")]
+ [DataRow("repl-host-and-services", DisplayName = "IReplHost and IServiceProvider overload")]
+ [Description("External-owner overloads diagnose an ignored Automatic request and pass the caller token unchanged to one-shot handlers.")]
+ public async Task When_ExternalOwnerReceivesExplicitAutomatic_Then_DiagnosticIsWrittenAndCallerTokenIsPreserved(
+ string overload)
+ {
+ var sut = ReplApp.Create();
+ CancellationToken captured = default;
+ using var cancellationTokenSource = new CancellationTokenSource();
+ using var diagnostics = new StringWriter();
+ using var replHost = new InMemoryHost(TextReader.Null, diagnostics);
+ using var host = new TestHost(sut.Services);
+ using var session = ReplSessionIO.SetSession(
+ TextWriter.Null,
+ TextReader.Null,
+ error: diagnostics);
+ var options = new ReplRunOptions
+ {
+ ProcessSignalHandling = ProcessSignalHandlingMode.Automatic,
+ };
+ sut.Map("work", (CancellationToken ct) =>
+ {
+ captured = ct;
+ return "ok";
+ });
+
+ var run = overload switch
+ {
+ "service-provider" => sut.RunAsync(
+ ["work", "--no-logo"], sut.Services, options, cancellationTokenSource.Token),
+ "host" => sut.RunAsync(
+ ["work", "--no-logo"], host, options, cancellationTokenSource.Token),
+ "repl-host" => sut.RunAsync(
+ ["work", "--no-logo"], replHost, options, cancellationTokenSource.Token),
+ "repl-host-and-services" => sut.RunAsync(
+ ["work", "--no-logo"], replHost, sut.Services, options, cancellationTokenSource.Token),
+ _ => throw new InvalidOperationException($"Unknown external-owner overload '{overload}'."),
+ };
+ var exitCode = await run.ConfigureAwait(false);
+
+ exitCode.Should().Be(0);
+ captured.Should().Be(cancellationTokenSource.Token);
+ diagnostics.ToString()
+ .Split("Ignoring ReplRunOptions.ProcessSignalHandling=Automatic", StringSplitOptions.None)
+ .Should().HaveCount(2);
+ }
+
[TestMethod]
[Description("Regression guard: verifies handler returns task of result so that exit code reflects resolved result.")]
public void When_HandlerReturnsTaskOfResult_Then_ExitCodeReflectsResolvedResult()
@@ -200,6 +446,21 @@ private sealed class TestCounter(int value) : ITestCounter
{
public int Value { get; } = value;
}
+
+ private sealed class InMemoryHost(TextReader input, TextWriter output) : IReplHost, IDisposable
+ {
+ public TextReader Input { get; } = input;
+ public TextWriter Output { get; } = output;
+ public void Dispose() { }
+ }
+
+ private sealed class TestHost(IServiceProvider services) : IHost
+ {
+ public IServiceProvider Services { get; } = services;
+ public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public void Dispose() { }
+ }
}
diff --git a/src/Repl.Tests/Given_ProcessSignalCancellationScope.cs b/src/Repl.Tests/Given_ProcessSignalCancellationScope.cs
new file mode 100644
index 0000000..fe9f49b
--- /dev/null
+++ b/src/Repl.Tests/Given_ProcessSignalCancellationScope.cs
@@ -0,0 +1,460 @@
+using AwesomeAssertions;
+
+namespace Repl.Tests;
+
+[TestClass]
+[DoNotParallelize]
+public sealed class Given_ProcessSignalCancellationScope
+{
+ [TestMethod]
+ [Description("Interactive CancelKeyHandler retains Ctrl+C ownership while a standalone signal scope surrounds the run.")]
+ public async Task When_InteractiveCancelHandlerIsActive_Then_StandaloneScopeDoesNotClaimCtrlC()
+ {
+ await using var scope = new ProcessSignalCancellationScope(default);
+ using var interactiveHandler = new CancelKeyHandler();
+
+ var result = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting();
+
+ result.Should().Be(ConsoleCancelKeyHandlingResult.AllowProcessTermination);
+ scope.ExitCode.Should().BeNull();
+ scope.Token.IsCancellationRequested.Should().BeFalse();
+ }
+
+ [TestMethod]
+ [Description("Ctrl+C is routed atomically to the active interactive handler instead of merely suppressing the standalone handler.")]
+ public async Task When_InteractiveCancelHandlerIsActive_Then_CtrlCIsRoutedToIt()
+ {
+ await using var scope = new ProcessSignalCancellationScope(default);
+ using var interactiveHandler = new CancelKeyHandler();
+ using var commandCancellation = new CancellationTokenSource();
+ interactiveHandler.SetCommandCts(commandCancellation);
+
+ var result = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting();
+
+ result.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ commandCancellation.IsCancellationRequested.Should().BeTrue();
+ scope.ExitCode.Should().BeNull();
+ scope.Token.IsCancellationRequested.Should().BeFalse();
+ }
+
+ [TestMethod]
+ [Description("The first process signal cancels every standalone scope participating in the same ownership epoch.")]
+ public async Task When_FirstCtrlCArrives_Then_AllActiveScopesAreCancelled()
+ {
+ await using var firstScope = new ProcessSignalCancellationScope(default);
+ await using var secondScope = new ProcessSignalCancellationScope(default);
+
+ var result = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting();
+
+ result.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ firstScope.ExitCode.Should().Be(ProcessSignalCoordinator.SigIntExitCode);
+ secondScope.ExitCode.Should().Be(ProcessSignalCoordinator.SigIntExitCode);
+ firstScope.Token.IsCancellationRequested.Should().BeTrue();
+ secondScope.Token.IsCancellationRequested.Should().BeTrue();
+ }
+
+ [TestMethod]
+ [Description("Ctrl+Break follows the cooperative first-signal policy on Windows.")]
+ public async Task When_FirstCtrlBreakArrivesOnWindows_Then_ActiveScopeIsCancelled()
+ {
+ await using var scope = new ProcessSignalCancellationScope(default);
+
+ var result = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting(
+ ConsoleSpecialKey.ControlBreak,
+ isWindows: true);
+
+ result.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ scope.ExitCode.Should().Be(ProcessSignalCoordinator.SigIntExitCode);
+ scope.Token.IsCancellationRequested.Should().BeTrue();
+ }
+
+ [TestMethod]
+ [Description("ControlBreak represents SIGQUIT on Unix and does not acquire the standalone SIGINT epoch.")]
+ public async Task When_ControlBreakArrivesOnUnix_Then_StandaloneScopeDoesNotClaimSigQuit()
+ {
+ await using var scope = new ProcessSignalCancellationScope(default);
+
+ var result = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting(
+ ConsoleSpecialKey.ControlBreak,
+ isWindows: false);
+
+ result.Should().Be(ConsoleCancelKeyHandlingResult.NotHandled);
+ scope.ExitCode.Should().BeNull();
+ scope.Token.IsCancellationRequested.Should().BeFalse();
+ }
+
+ [TestMethod]
+ [Description("Disposing the interactive claim atomically hands Ctrl+C ownership back to the active standalone scope.")]
+ public async Task When_InteractiveHandlerIsDisposed_Then_StandaloneScopeClaimsCtrlC()
+ {
+ await using var scope = new ProcessSignalCancellationScope(default);
+ using var interactiveHandler = new CancelKeyHandler();
+ interactiveHandler.Dispose();
+
+ var result = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting();
+
+ result.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ scope.ExitCode.Should().Be(ProcessSignalCoordinator.SigIntExitCode);
+ scope.Token.IsCancellationRequested.Should().BeTrue();
+ }
+
+ [TestMethod]
+ [Description("A late-joining standalone scope cannot reinterpret the process-wide second Ctrl+C as a first signal.")]
+ public async Task When_ScopeJoinsAfterFirstCtrlC_Then_SecondCtrlCFallsThroughProcessWide()
+ {
+ await using var firstScope = new ProcessSignalCancellationScope(default);
+
+ var firstSignal = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting();
+ await using var lateScope = new ProcessSignalCancellationScope(default);
+ var secondSignal = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting();
+
+ firstSignal.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ firstScope.Token.IsCancellationRequested.Should().BeTrue();
+ lateScope.Token.IsCancellationRequested.Should().BeTrue();
+ secondSignal.Should().Be(ConsoleCancelKeyHandlingResult.AllowProcessTermination);
+ }
+
+ [TestMethod]
+ [DataRow(false, DisplayName = "Dispose old owner, then register replacement")]
+ [DataRow(true, DisplayName = "Register replacement, then dispose old owner")]
+ [Description("An interactive replacement registered during dispatch is reselected before standalone ownership can claim Ctrl+C.")]
+ public async Task When_InteractiveOwnershipChangesDuringSelection_Then_ReplacementKeepsPriority(
+ bool registerReplacementFirst)
+ {
+ await using var scope = new ProcessSignalCancellationScope(default);
+ var initialSelectionCaptured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var releaseSelection = new ManualResetEventSlim();
+ using var oldHandler = new CancelKeyHandler();
+ using var oldCommandCancellation = new CancellationTokenSource();
+ using var replacementCommandCancellation = new CancellationTokenSource();
+ oldHandler.SetCommandCts(oldCommandCancellation);
+ CancelKeyHandler? replacementHandler = null;
+ try
+ {
+ var dispatchTask = Task.Run(() => ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting(
+ ConsoleSpecialKey.ControlC,
+ () =>
+ {
+ initialSelectionCaptured.TrySetResult();
+ if (!releaseSelection.Wait(TimeSpan.FromSeconds(5)))
+ {
+ throw new TimeoutException("Timed out while holding the initial Ctrl+C selection.");
+ }
+ }));
+ await initialSelectionCaptured.Task.WaitAsync(timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+
+ if (registerReplacementFirst)
+ {
+ replacementHandler = new CancelKeyHandler();
+ replacementHandler.SetCommandCts(replacementCommandCancellation);
+ oldHandler.Dispose();
+ }
+ else
+ {
+ oldHandler.Dispose();
+ replacementHandler = new CancelKeyHandler();
+ replacementHandler.SetCommandCts(replacementCommandCancellation);
+ }
+
+ releaseSelection.Set();
+ var result = await dispatchTask.WaitAsync(timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+
+ result.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ oldCommandCancellation.IsCancellationRequested.Should().BeFalse();
+ replacementCommandCancellation.IsCancellationRequested.Should().BeTrue();
+ scope.ExitCode.Should().BeNull();
+ }
+ finally
+ {
+ releaseSelection.Set();
+ replacementHandler?.Dispose();
+ }
+ }
+
+ [TestMethod]
+ [Description("A draining cancellation callback keeps the process epoch alive for late scopes and second-signal escalation.")]
+ public async Task When_CancellationDrainIsPending_Then_LateScopeInheritsEpoch()
+ {
+ var firstScope = new ProcessSignalCancellationScope(default);
+ ProcessSignalCancellationScope? lateScope = null;
+ var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var releaseCallback = new ManualResetEventSlim();
+ using var registration = firstScope.Token.Register(() =>
+ {
+ callbackStarted.TrySetResult();
+ if (!releaseCallback.Wait(TimeSpan.FromSeconds(5)))
+ {
+ throw new TimeoutException("Timed out while holding signal cancellation open.");
+ }
+ });
+
+ try
+ {
+ var firstDispatchTask = Task.Run(() => ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting());
+ await callbackStarted.Task.WaitAsync(timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+ var disposeTask = firstScope.DisposeAsync().AsTask();
+ disposeTask.IsCompleted.Should().BeFalse();
+
+ lateScope = new ProcessSignalCancellationScope(default);
+ lateScope.Token.IsCancellationRequested.Should().BeTrue();
+ ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting()
+ .Should().Be(ConsoleCancelKeyHandlingResult.AllowProcessTermination);
+
+ releaseCallback.Set();
+ await firstDispatchTask.WaitAsync(timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+ await disposeTask.WaitAsync(timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+ }
+ finally
+ {
+ releaseCallback.Set();
+ await firstScope.DisposeAsync().ConfigureAwait(false);
+ if (lateScope is not null)
+ {
+ await lateScope.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
+ [TestMethod]
+ [Description("A cancellation callback can join the draining epoch without re-entering the process coordinator gate. This exercises the documented shape rather than proving deadlock freedom: callbacks start after both gates are released, so the callback never contends for a gate it already holds.")]
+ public async Task When_CancellationCallbackStartsScope_Then_NewScopeIsCancelledWithoutDeadlock()
+ {
+ await using var firstScope = new ProcessSignalCancellationScope(default);
+ var joinedScopeSource = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ using var registration = firstScope.Token.Register(() =>
+ joinedScopeSource.TrySetResult(new ProcessSignalCancellationScope(default)));
+
+ ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting()
+ .Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ var joinedScope = await joinedScopeSource.Task.WaitAsync(timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+ await using var configuredJoinedScope = joinedScope.ConfigureAwait(false);
+
+ joinedScope.Token.IsCancellationRequested.Should().BeTrue();
+ ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting()
+ .Should().Be(ConsoleCancelKeyHandlingResult.AllowProcessTermination);
+ }
+
+ [TestMethod]
+ [Description("A scope remains signal-owned until its removal is atomic, so a signal in the pre-unregister window cannot be suppressed while the run still returns success.")]
+ public async Task When_DisposalStartsBeforeAtomicUnregister_Then_SignalStillCancelsTheRun()
+ {
+ var scope = new ProcessSignalCancellationScope(default);
+ var executionToken = scope.Token;
+ var disposalPaused = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var resumeDisposal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var disposeTask = Task.Run(async () =>
+ await scope.DisposeForTestingAsync(() =>
+ {
+ disposalPaused.SetResult();
+ resumeDisposal.Task.GetAwaiter().GetResult();
+ }).ConfigureAwait(false));
+
+ await disposalPaused.Task.WaitAsync(timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+ var signalResult = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting();
+ resumeDisposal.SetResult();
+ await disposeTask.WaitAsync(timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+
+ signalResult.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ executionToken.IsCancellationRequested.Should().BeTrue();
+ scope.ResolveExitCode(runExitCode: 0).Should().Be(130);
+ }
+
+ [TestMethod]
+ [Description("Two concurrent process-signal dispatches produce exactly one cooperative first signal. This is a smoke test, not proof of atomicity: the coordinator gate serializes both dispatches, so the test has no interleaving control and would also pass against a non-atomic claim.")]
+ public async Task When_TwoConcurrentDispatches_Then_ExactlyOneIsSuppressed()
+ {
+ await using var scope = new ProcessSignalCancellationScope(default);
+ using var start = new ManualResetEventSlim();
+ ConsoleCancelKeyHandlingResult DispatchAfterStart()
+ {
+ if (!start.Wait(TimeSpan.FromSeconds(5)))
+ {
+ throw new TimeoutException("Timed out waiting to race process signals.");
+ }
+
+ return ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting();
+ }
+
+ var firstDispatch = Task.Run(DispatchAfterStart);
+ var secondDispatch = Task.Run(DispatchAfterStart);
+
+ start.Set();
+ var results = await Task.WhenAll(firstDispatch, secondDispatch)
+ .WaitAsync(timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+
+ results.Should().ContainSingle(
+ result => result == ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ results.Should().ContainSingle(
+ result => result == ConsoleCancelKeyHandlingResult.AllowProcessTermination);
+ }
+
+ [TestMethod]
+ [Description("The process-wide signal claim resets after the final standalone scope is disposed.")]
+ public async Task When_LastScopeIsDisposed_Then_NextScopeStartsANewSignalEpoch()
+ {
+ await using (var firstScope = new ProcessSignalCancellationScope(default))
+ {
+ ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting()
+ .Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ }
+
+ await using var nextScope = new ProcessSignalCancellationScope(default);
+
+ ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting()
+ .Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ nextScope.Token.IsCancellationRequested.Should().BeTrue();
+ }
+
+ [TestMethod]
+ [Description("An explicit non-zero run result takes precedence over a claimed signal code, while a successful result uses the signal code.")]
+ public async Task When_ResolvingExitCodeAfterSignal_Then_ExplicitFailureIsPreserved()
+ {
+ var scope = new ProcessSignalCancellationScope(default);
+ ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting();
+ await scope.DisposeAsync().ConfigureAwait(false);
+
+ scope.ResolveExitCode(runExitCode: 2).Should().Be(2);
+ scope.ResolveExitCode(runExitCode: 0).Should().Be(ProcessSignalCoordinator.SigIntExitCode);
+ }
+
+ [TestMethod]
+ [Description("A throwing cancellation callback cannot replace the conventional signal exit policy during scope disposal.")]
+ public async Task When_SignalCancellationCallbackThrows_Then_DisposalStillCompletes()
+ {
+ using var error = new StringWriter();
+ using var session = ReplSessionIO.SetSession(TextWriter.Null, TextReader.Null, error: error);
+ var scope = new ProcessSignalCancellationScope(default);
+ using var registration = scope.Token.Register(
+ static () => throw new InvalidOperationException("callback failure"));
+
+ var result = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting();
+ var act = async () => await scope.DisposeAsync().ConfigureAwait(false);
+
+ result.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ await act.Should().NotThrowAsync().ConfigureAwait(false);
+ scope.ExitCode.Should().Be(ProcessSignalCoordinator.SigIntExitCode);
+ error.ToString().Should().Contain("process-signal cancellation callback")
+ .And.Contain(nameof(InvalidOperationException));
+ }
+
+ [TestMethod]
+ [Description("A failed signal registration degrades to caller-owned handling instead of aborting the run. Automatic is the CLI-profile default, so an environment that rejects a signal registration must not turn a working command into one that never executes.")]
+ public async Task When_SignalRegistrationFails_Then_RunContinuesCallerOwned()
+ {
+ using var error = new StringWriter();
+ using var session = ReplSessionIO.SetSession(TextWriter.Null, TextReader.Null, error: error);
+ using var fault = ProcessSignalCoordinator.IsolateRegistrationsForTesting(
+ new PlatformNotSupportedException("signal registration rejected"));
+
+ // Construction must not throw: that is the whole behavior under test.
+ await using var scope = new ProcessSignalCancellationScope(default);
+
+ scope.Token.IsCancellationRequested.Should().BeFalse();
+ scope.ExitCode.Should().BeNull();
+ error.ToString().Should().Contain("Failed to install automatic process-signal handling")
+ .And.Contain(nameof(PlatformNotSupportedException));
+ }
+
+ [TestMethod]
+ [Description("A failed signal registration latches, so later runs in the same process do not retry it. Without the latch every subsequent run repeats a registration the environment has already refused and re-emits the same diagnostic, once per run.")]
+ public async Task When_SignalRegistrationFailed_Then_LaterRunsDoNotRetry()
+ {
+ using var error = new StringWriter();
+ using var session = ReplSessionIO.SetSession(TextWriter.Null, TextReader.Null, error: error);
+ using var fault = ProcessSignalCoordinator.IsolateRegistrationsForTesting(
+ new PlatformNotSupportedException("signal registration rejected"));
+ await using (var first = new ProcessSignalCancellationScope(default))
+ {
+ first.ExitCode.Should().BeNull();
+ }
+
+ await using var second = new ProcessSignalCancellationScope(default);
+
+ second.Token.IsCancellationRequested.Should().BeFalse();
+ // Split yields one more part than there are occurrences, so a single diagnostic gives two parts.
+ error.ToString().Split("Failed to install automatic process-signal handling").Should().HaveCount(2);
+ }
+
+ [TestMethod]
+ [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)]
+ [Description("A registration that fails after SIGTERM was registered disposes the orphan instead of leaking it. This is the only ordering that reaches the cleanup, and a leaked PosixSignalRegistration would keep suppressing SIGTERM for a process that has already been told the bridge is caller-owned.")]
+ public async Task When_RegistrationFailsAfterSigTerm_Then_TheOrphanedRegistrationIsReleased()
+ {
+ using var error = new StringWriter();
+ using var session = ReplSessionIO.SetSession(TextWriter.Null, TextReader.Null, error: error);
+ using (var isolation = ProcessSignalCoordinator.IsolateRegistrationsForTesting(
+ new PlatformNotSupportedException("cancel-key registration rejected"),
+ faultAfterSigTermRegistration: true))
+ {
+ await using var degraded = new ProcessSignalCancellationScope(default);
+
+ degraded.Token.IsCancellationRequested.Should().BeFalse();
+ error.ToString().Should().Contain("Failed to install automatic process-signal handling");
+ }
+
+ // A leaked registration would still be claiming SIGTERM under a stale generation. After the
+ // isolation scope tears down and a fresh run installs its own, signals must be claimed again.
+ await using var scope = new ProcessSignalCancellationScope(default);
+
+ ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting()
+ .Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ }
+
+ [TestMethod]
+ [Description("The registration-fault test scope leaves the coordinator able to claim signals again. The scope is the only thing that resets process-wide registration state, so if it restores a registration whose captured generation is stale, every later scope in the process silently stops claiming signals.")]
+ public async Task When_RegistrationFaultScopeIsDisposed_Then_SignalsAreClaimedAgain()
+ {
+ // Install the real registrations first: the trap only exists when the scope has something
+ // to restore, which is the state every test after the first one runs in.
+ await using (var warmUp = new ProcessSignalCancellationScope(default))
+ {
+ warmUp.ExitCode.Should().BeNull();
+ }
+
+ using (var fault = ProcessSignalCoordinator.IsolateRegistrationsForTesting(
+ new PlatformNotSupportedException("signal registration rejected")))
+ {
+ await using var degraded = new ProcessSignalCancellationScope(default);
+ }
+
+ await using var scope = new ProcessSignalCancellationScope(default);
+
+ var result = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting();
+
+ result.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination);
+ scope.ExitCode.Should().Be(ProcessSignalCoordinator.SigIntExitCode);
+ }
+
+ [TestMethod]
+ [DataRow(true, false, false, false, DisplayName = "Android")]
+ [DataRow(false, true, false, false, DisplayName = "Browser")]
+ [DataRow(false, false, true, false, DisplayName = "iOS family, which OperatingSystem.IsIOS also reports for Mac Catalyst")]
+ [DataRow(false, false, false, true, DisplayName = "tvOS")]
+ [Description("Each mobile platform flag on its own disables the signal bridge. One row per flag so a duplicated operand in the predicate cannot pass unnoticed; Mac Catalyst rides the iOS row because .NET compiles the mobile PosixSignalRegistration implementation there and OperatingSystem.IsIOS reports it.")]
+ public void When_APlatformFlagIsSet_Then_SignalBridgeIsUnsupported(
+ bool isAndroid,
+ bool isBrowser,
+ bool isIOS,
+ bool isTvOS)
+ {
+ ProcessSignalCoordinator.IsSignalBridgeSupportedForTesting(
+ isAndroid,
+ isBrowser,
+ isIOS,
+ isTvOS).Should().BeFalse();
+ }
+
+ [TestMethod]
+ [Description("A platform with no mobile flag keeps the signal bridge, so the platform predicate is not vacuously false for every input.")]
+ public void When_NoPlatformFlagIsSet_Then_SignalBridgeIsSupported()
+ {
+ ProcessSignalCoordinator.IsSignalBridgeSupportedForTesting(
+ isAndroid: false,
+ isBrowser: false,
+ isIOS: false,
+ isTvOS: false).Should().BeTrue();
+ }
+
+}
diff --git a/src/Repl.Tests/Given_RunOptions.cs b/src/Repl.Tests/Given_RunOptions.cs
index 1682df3..5a60348 100644
--- a/src/Repl.Tests/Given_RunOptions.cs
+++ b/src/Repl.Tests/Given_RunOptions.cs
@@ -5,6 +5,22 @@ namespace Repl.Tests;
[TestClass]
public sealed class Given_RunOptions
{
+ [TestMethod]
+ [Description("Regression guard: verifies a new run-options record leaves process-signal handling unspecified.")]
+ public void When_CreatingRunOptions_Then_ProcessSignalHandlingIsNull()
+ {
+ var options = new ReplRunOptions();
+
+ options.ProcessSignalHandling.Should().BeNull();
+ }
+
+ [TestMethod]
+ [Description("Regression guard: verifies the zero value of the mode enum leaves signals to the caller. Enum zero is what an unset configuration field, a zero-initialized struct, or an explicit default() yields, so it must agree with the caller-owned application default rather than silently claiming process-wide signal ownership.")]
+ public void When_UsingDefaultProcessSignalHandlingMode_Then_ValueIsNone()
+ {
+ default(ProcessSignalHandlingMode).Should().Be(ProcessSignalHandlingMode.None);
+ }
+
[TestMethod]
[Description("Regression guard: verifies hosted-service lifecycle defaults to none so that runs avoid orchestration unless explicitly requested.")]
public void When_CreatingRunOptions_Then_HostedServiceLifecycleDefaultsToNone()