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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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())`)
Expand Down
20 changes: 20 additions & 0 deletions docs/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
138 changes: 138 additions & 0 deletions docs/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>an explicit Automatic is diagnosed and ignored"]
B -->|"Internally configured services"| D{"ReplRunOptions.ProcessSignalHandling"}
D -->|"None"| E["Caller-owned<br/>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<br/>the handler receives a linked run-scoped token"]
G -->|"Android, browser, iOS incl. Mac Catalyst, tvOS"| I["Diagnostic, then no bridge<br/>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<br/>OS default applies"]
D{"An interactive handler is registered?"}
D -->|"yes"| E["Interactive handler decides<br/>first press cancels the running command"]
D -->|"no"| F{"An automatic standalone run is active?"}
F -->|"yes"| G["The standalone epoch claims it<br/>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.
69 changes: 69 additions & 0 deletions eng/ci/process-signal-stress.sh
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading