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()