Skip to content

fix(runtime): cancel standalone runs on process signals - #80

Open
autocarl wants to merge 10 commits into
yllibed:mainfrom
autocarl:agent/issue-79-signal-handling
Open

fix(runtime): cancel standalone runs on process signals#80
autocarl wants to merge 10 commits into
yllibed:mainfrom
autocarl:agent/issue-79-signal-handling

Conversation

@autocarl

@autocarl autocarl commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • bridge the first Ctrl+C console event—or Ctrl+Break on Windows—received by process-owning standalone ReplApp.Run(...) / RunAsync(...) calls into cooperative handler cancellation
  • also bridge the first SIGTERM on supported Unix platforms
  • coordinate signal ownership and first/second-signal escalation through a single process-wide epoch shared by overlapping runs
  • preserve the interactive command-cancellation policy through the same console-key broker, including atomic acquisition and teardown hand-offs
  • preserve caller-owned behavior for an unprofiled ReplApp.Create(), embedded profiles, and externally managed host/provider overloads
  • let cancellation callbacks drain before the signal epoch resets and resolve the final exit code only after signal ownership is released
  • diagnose unsupported bridges, registration failures, ignored external-host Automatic requests, and consumer cancellation-callback failures
  • document exit-code conventions, token lifetime, host ownership, diagnostics, escalation, and platform limits

Fixes #79.

Ownership model

ReplRunOptions.ProcessSignalHandling is nullable. null inherits the active app/profile default:

  • UseCliProfile() / UseDefaultInteractive(): Automatic
  • unprofiled ReplApp.Create(): None
  • UseEmbeddedConsoleProfile(): None

Supplying unrelated options therefore no longer makes an embedded console silently install standalone process handlers. An embedded or unprofiled caller can still opt in explicitly for one run.

Automatic applies to standalone overloads that use internally configured services. External IServiceProvider, IHost, and IReplHost overloads retain ownership of process signals. Their one-shot handlers receive the caller token unchanged. An explicit Automatic request on those overloads is diagnosed and ignored. If one of those runs enters Repl's interactive loop, each command receives a command-scoped token linked to the caller token so the loop can retain its separate Ctrl+C policy.

The process registrations are installed lazily once and remain inert without an active automatic run. This avoids runtime callback-snapshot races during registration teardown. A first signal atomically claims an epoch, cancels every active scope, and immediately cancels late joiners. A second signal falls through to the operating-system default. The epoch resets only after its final scope and all signal-triggered cancellation callbacks have drained.

Cancellation-callback draining is intentionally unbounded. Resetting an epoch while a callback remains active could cause a later signal to be suppressed as a new first signal. If draining never completes, every later supported signal falls through to operating-system termination.

Android, browser, iOS (including Mac Catalyst), and tvOS diagnose the unavailable bridge and leave cancellation to their platform host. .NET classifies Mac Catalyst in its iOS-like mobile family and compiles the platform-not-supported POSIX signal registration there.

Exit behavior

  • SIGINT / Ctrl+C returns 130 (128 + 2)
  • Ctrl+Break returns 130 as a Repl compatibility policy on Windows
  • SIGTERM returns 143 (128 + 15)
  • an explicit non-zero handler result takes precedence over the signal code
  • a second signal is not suppressed, so cleanup is not guaranteed to finish
  • no automatic grace-period timeout is imposed
  • Unix SIGQUIT is left unclaimed; .NET surfaces it as ControlBreak, but Repl does not reinterpret it as SIGINT

The 128 + signal number calculation is documented as a widely adopted Unix/Bash convention, not a universal POSIX, .NET, or Windows guarantee.

Regression coverage

Real child-process tests cover:

  • synchronous and asynchronous CLI-profile runs
  • SIGINT cooperative cleanup and exit 130
  • SIGTERM cooperative cleanup and exit 143
  • ProcessSignalHandlingMode.None
  • explicit non-zero handler exit-code precedence after cancellation
  • second-SIGTERM forced termination during deliberately blocked cleanup
  • captured stdout/stderr diagnostics and bounded child cleanup

Deterministic unit tests cover:

  • all already-active scopes receiving the first signal
  • late scopes inheriting the draining epoch
  • exactly one winner when two signals race
  • interactive ownership replacement in both acquisition/disposal orders
  • the pre-unregister disposal window remaining signal-owned
  • epoch retention while cancellation callbacks are blocked
  • reentrant scope creation from a cancellation callback without deadlock
  • throwing cancellation callbacks being diagnosed without replacing the signal exit policy
  • Ctrl+Break on Windows plus unclaimed SIGQUIT/ControlBreak on Unix
  • unsupported iOS-family platform selection, including Mac Catalyst
  • profile inheritance, unprofiled caller ownership, and explicit embedded opt-in
  • external provider/host diagnostics, one-shot caller-token identity, and interactive linked-token documentation
  • linked handler-token lifetime

Validation

  • dotnet build src/Repl.slnx -c Release -warnaserror --no-restore --nologo0 warnings, 0 errors
  • full suite — 1,499 total; 1,498 passed; 0 failed; 1 existing opt-in MCP Inspector smoke test skipped
  • ./eng/ci/process-signal-stress.sh50/50 unit iterations × 17 tests and 20/20 integration iterations × 8 tests
  • npx --yes markdownlint-cli2@0.19.1 CHANGELOG.md docs/best-practices.md docs/configuration-reference.md src/Repl.Defaults/README.md0 errors
  • git diff --check — clean

Follow-up design work

Kept out of this focused change and tracked separately:

Bridge SIGTERM into the standalone RunAsync cancellation token so handlers can complete cleanup before the process exits with code 143. Keep externally hosted runs caller-owned and expose an opt-out for custom process lifecycles.\n\nAdd a real child-process regression test for yllibed#79.
Handle SIGINT cooperatively for standalone runs on Unix and Windows while yielding to the existing interactive CancelKeyHandler semantics. Keep the embedded console profile caller-owned by default and cover second-signal fallback with a real child process.
@autocarl autocarl changed the title fix(runtime): cancel standalone runs on SIGTERM fix(runtime): cancel standalone runs on process signals Sep 3, 2026
@carldebilly

Copy link
Copy Markdown
Member

@codex A review of this please.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T03:50:42.695378Z 09a64b9 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5d5ae4286b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Repl.Defaults/ReplApp.cs Outdated
Comment thread src/Repl.Core/Console/CancelKeyHandler.cs Outdated
Comment thread src/Repl.Defaults/ProcessSignalCancellationScope.cs Outdated
Centralize Ctrl+C and SIGTERM ownership in process-wide coordinators. Preserve profile defaults, drain cancellation epochs before reset, and resolve exit codes only after releasing signal ownership. Add deterministic race coverage plus real sync/async signal integration tests.
Document profile inheritance, first- and second-signal behavior, 130/143 conventions, exit-code precedence, token lifetime, diagnostics, host ownership, and platform limitations.
Preserve caller ownership for unprofiled and externally managed runs, cover Ctrl+Break and Mac Catalyst, diagnose callback and registration failures, and add deterministic concurrency plus replayable stress coverage.
@autocarl

autocarl commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Review-hardening update, superseding the earlier summary, is published through 3a4b0ce.

Highlights:

  • keeps an unprofiled ReplApp.Create() caller-owned while CLI/default-interactive profiles opt into Automatic;
  • handles Ctrl+Break as Repl's 130 compatibility policy on Windows, while leaving Unix SIGQUIT/ControlBreak unclaimed;
  • keeps Mac Catalyst on the unsupported iOS-family path because .NET 10 compiles PosixSignalRegistration.PlatformNotSupported there;
  • diagnoses ignored Automatic requests on externally managed overloads: one-shot handlers receive the caller token unchanged, while interactive commands use a linked command-scoped token for Ctrl+C;
  • isolates and diagnoses consumer cancellation-callback failures without masking coordinator faults or replacing an established signal exit policy;
  • keeps production and test console-key filtering on one shared path;
  • adds executable eng/ci/process-signal-stress.sh so the concurrency evidence is replayable.

Exact-head local validation:

  • build: 0 warnings, 0 errors;
  • full suite: 1,498 passed, 0 failed, 1 existing opt-in skip;
  • stress: 50/50 × 17 unit tests and 20/20 × 8 process tests;
  • markdownlint: 0 errors;
  • git diff --check: clean.

Broader follow-ups remain tracked in #81, #82, #83, and #84. Exact-head CI for 3a4b0ce is green on Ubuntu, macOS, and Windows; pack, documentation lint, and real-shell smoke jobs also passed. The PR-only release job was skipped as expected. Run: https://github.com/yllibed/repl/actions/runs/33785494030

Keep Mac Catalyst on the unsupported mobile path, leave Unix SIGQUIT to the operating system, and clarify one-shot versus interactive cancellation-token contracts with real-process regression coverage.
@carldebilly

Copy link
Copy Markdown
Member

@codex a review please.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 3a4b0ce02c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

… None

Make `ProcessSignalHandlingMode.None` the zero value so an unset configuration
field or a zero-initialized value agrees with the caller-owned application
default. Enum values are baked into consumer assemblies, so this is only free
before release; every existing use is by name.

Treat a rejected signal registration as a degraded bridge rather than a fatal
error: diagnose it, latch the attempt, and continue caller-owned, matching the
unsupported-platform path. Automatic is the CLI-profile default, so a
restricted environment previously turned a working command into one that never
executed, for that run and every later run in the process.

Also:

- run the process-signal stress harness in CI at low iteration counts, so the
  epoch races it guards are gated instead of relying on a local run
- state signal ownership, exit codes and the per-run opt-out in the
  `UseCliProfile` and `UseDefaultInteractive` summaries, where a consumer meets
  them, and pin the interactive profile's ownership with a test
- record the exit-code and handler-token changes for existing profile users
  under a CHANGELOG `Changed` heading
- assert the second-signal escalation diagnostic, list the unclaimed SIGQUIT
  exit code in the reference table, and cover each platform flag on its own row
  instead of restating the predicate once
- scope the race and reentrancy test descriptions to what they actually
  exercise: the gate serializes both dispatches, so neither proves atomicity
The script built the solution without `--no-restore`, so the incremental restore
audited no projects and tripped the CI-only NuGet audit assertion in
`src/Directory.Solution.targets` whenever the caller had already restored.

Follow the repository convention used by every workflow job: an explicit
`dotnet restore --force`, then a build with `--no-restore`. The script stays
self-contained when run on its own.
Three parts of this contract are hard to hold in the head from prose alone:
which of the three inputs decides a run's mode, how the process-wide epoch
moves between its states, and which console key each owner actually claims.

Add a mermaid diagram for each, next to the prose it illustrates. They carry
the skeleton only — the epoch diagram labels its edges with the step numbers of
the list above it rather than restating them, so the two cannot drift apart.

Each diagram was rendered and read before landing; the ownership flow also
records that a rejected registration degrades to caller-owned handling.
@carldebilly
carldebilly marked this pull request as ready for review September 4, 2026 03:13
@carldebilly

Copy link
Copy Markdown
Member

@codex last review?

@chatgpt-codex-connector

Copy link
Copy Markdown

Review Finding

[P2] Restored signal registrations use an invalidated generation

RegistrationFaultScope.Dispose() restores the previous console and SIGTERM registration objects and restores s_registrationsInitialized, but then increments s_generation. Those restored callbacks captured the prior generation when they were created, so they will subsequently return NotHandled. Because the initialized flag is also restored, later scopes will not reinstall working callbacks. Thus, after either registration-failure test runs, real process signals in the same test process are silently no longer bridged. Preserve and restore the original generation, or dispose the old registrations and install fresh ones after fault injection instead. src/Repl.Defaults/ProcessSignalCoordinator.csL292-L309

No code changes were made, so no commit or follow-up pull request was created.

Testing

  • git diff --check 3a4b0ce02c..HEAD
  • ⚠️ dotnet test src/Repl.Tests/Repl.Tests.csproj -c Release --no-restore --nologo --filter FullyQualifiedName~Given_ProcessSignalCancellationScope (dotnet is not installed in this environment)

View task →

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c57fa296df

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +251 to +252
runExitCode = await RunWithServicesAsync(args, provider, runOptions, signals.Token)
.ConfigureAwait(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve signal codes when hosted-service startup is cancelled

When automatic signal handling is combined with HostedServiceLifecycleMode.Head and SIGINT/SIGTERM arrives while an IHostedService.StartAsync is pending, this token cancellation throws OperationCanceledException, but HostedServiceLifecycleCoordinator.StartAsync wraps it in HostedServiceLifecycleException; RunWithServicesAsync then converts it to exit code 1, and ResolveExitCode preserves that nonzero value. Consequently the newly documented cooperative signal path returns 1 instead of 130/143 whenever the signal interrupts hosted-service startup; cancellation caused by the signal should be allowed to reach the outer signal-aware catch rather than being treated as a lifecycle failure.

Useful? React with 👍 / 👎.

An independent review of the previous three commits found the registration
fault scope was a trap. Registrations capture the generation counter they were
created under; the scope put a saved registration object back after the counter
had moved, so it came back permanently stale. Any scope test running after a
fault test then got `NotHandled` instead of a claimed signal, and the latch made
sure the bridge was never reinstalled. It only stayed green because MSTest
happens to run this class in declaration order, and the stress harness loops
exactly this class.

Replace it with an isolation scope that tears registrations down on both entry
and exit and lets the next run install fresh ones. A regression test warms up
real registrations first, since the trap only exists when there is something to
restore.

Also from that review:

- collapse the two registration-degradation channels — an `out bool` for
  "unsupported" and a nullable record for "rejected" — into one outcome with a
  single diagnostic site, so a third reason does not need a third channel
- let the fault be injected after the SIGTERM registration, which is the only
  ordering that reaches the orphaned-registration cleanup, and cover it
- correct three claims that were wrong: `IsCancellationRequested` does not throw
  on a disposed source, it silently reports `false`, which is the dangerous case
  a consumer needs told; `UseDefaultInteractive` does not report 130 for a Ctrl+C
  inside an interactive command, which cancels only that command; and the
  console-key result enum is aggregated across handlers, so callers are not the
  only reader of its third state
- correct the two diagrams the previous commit got wrong: a run whose bridge
  failed to install still receives a linked run-scoped token, and a degraded
  process still starts and stops runs, it just never reaches Claimed
- state the ownership rule once on `ProcessSignalHandlingMode.Automatic` and have
  both profile summaries point at it, rather than three drifting copies
- assert that the run-scoped token is linked to the caller token, not merely
  distinct from it
- rename the concurrency test to what it asserts, now that its description
  admits it cannot prove atomicity
- give the stress harness its own CI job, so unrelated setup cannot silently
  drop signal coverage and the check name describes what it gates
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One-shot RunAsync(args, ct) never cancels: no signal source outside the interactive session

2 participants