Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,37 @@ this file cannot name the build; the PR and issue numbers are the durable anchor
- Exit codes are not range-checked. Keep them within `0`-`255`: POSIX `wait` exposes only the low
eight bits to the parent process.

### 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 reports a successful or cancelled run as `ReplExecutionOutcomeKind.Interrupted`, which resolves through `ExitCodes.Interrupted` and defaults to exit code `130`. On supported Unix platforms, SIGTERM behaves the same way with `143`. A run that already produced a refusal or a failure keeps reporting it. A subsequent signal uses the operating-system default, and stderr diagnostics identify both steps. Explicit non-zero handler exit codes remain authoritative.
Comment thread
carldebilly marked this conversation as resolved.

### 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. The interruption goes through the exit-code policy, so `ExitCodes.Interrupted` overrides
those defaults and `ExitCodes.Resolver` observes it like any other outcome. 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 @@ -359,4 +359,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)
Loading
Loading