Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ jobs:
set -euo pipefail
bash ./eng/ci/shell-completion-real-shell-smoke.sh ./src/Repl.ShellCompletionTestHost/bin/Release/net10.0/Repl.ShellCompletionTestHost

# Low iteration counts keep the per-PR cost small; the epoch races this guards surface quickly.
# Raise them locally when investigating a flake: ./eng/ci/process-signal-stress.sh 50 20
- name: Process signal stress checks
shell: bash
run: |
set -euo pipefail
bash ./eng/ci/process-signal-stress.sh 3 2

build-test-pack:
name: Build, Test, Pack
runs-on: ubuntu-latest
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ 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, neither of which requires a code
change to keep working — select `ProcessSignalHandlingMode.None` to restore 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. A handler that
stored the token and used it after the run returned — for detached or background work — will now
observe `ObjectDisposedException` on that stored token. 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)
136 changes: 136 additions & 0 deletions docs/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,142 @@ 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 caller-owned"]
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 once per
process and stay installed. With no automatic
run active, a signal falls through to the OS.
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" \
23

run_stress \
"process-signal integration stress" \
"$integration_iterations" \
src/Repl.IntegrationTests/Repl.IntegrationTests.csproj \
"FullyQualifiedName~Given_ProcessSignals" \
8
Loading
Loading