fix(spectre)!: Connect the cancellation token to the running command - #42
Conversation
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
🤖 CodeAnt AI — Review Status
|
Reviewer's GuideConnects the application’s CancellationTokenSource to Spectre’s command execution pipeline and ensures Ctrl+C both cooperatively cancels commands and can still terminate the process, while making cancellation wiring mandatory for configurators/executors and updating tests and changelog accordingly. Sequence diagram for cancellation-aware command executionsequenceDiagram
actor User
participant AppBuilder
participant CommandAppExecutor
participant SpectreCommandApp
participant Command
AppBuilder->>CommandAppExecutor: Run(args)
CommandAppExecutor->>SpectreCommandApp: Run(args, cancellationTokenSource.Token)
SpectreCommandApp->>Command: Execute(cancellationToken)
User->>AppBuilder: Ctrl+C
AppBuilder->>AppBuilder: OnCancelKeyPress(sender, e)
AppBuilder->>AppBuilder: cancellationTokenSource.Cancel()
AppBuilder-->>Command: cancellationToken.IsCancellationRequested
Command-->>SpectreCommandApp: Stop cooperatively
Sequence diagram for the Ctrl+C force-exit escape hatchsequenceDiagram
actor User
participant AppBuilder
participant Process
User->>AppBuilder: First Ctrl+C
AppBuilder->>AppBuilder: OnCancelKeyPress(sender, e)
AppBuilder->>AppBuilder: Console.CancelKeyPress -= OnCancelKeyPress
AppBuilder->>AppBuilder: cancellationTokenSource.Cancel()
User->>Process: Second Ctrl+C
Process-->>User: Default console behavior terminates process
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (2)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe change wires the ChangesCancellation token flow
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to The change connects Ctrl+C to running commands and preserves a force-exit path while avoiding shutdown prompts after cancellation; no actionable merge-blocking risk remains beyond normal checks and review. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Description checkExplanation The description thoroughly explains the changes, related issues, breaking API changes, testing, and expected behavior. It omits the exact template headings and checklist, but the required information is mostly present. Full details: Docstring CoverageExplanation Docstring coverage is 36.84% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 38 functions across 6 files. (2 skipped: 2 unsupported.)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
|
Bito Automatic Review Skipped - Branch Excluded |
There was a problem hiding this comment.
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="src/Spectre/CommandLine.Spectre/AppBuilder.cs" line_range="44-50" />
<code_context>
+ // third-party library in a tight loop.
+ void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
+ {
+ Console.CancelKeyPress -= OnCancelKeyPress;
+ e.Cancel = true;
+ AnsiConsole.WriteLine("Shutting down... press Ctrl+C again to force an exit.");
+ cts.Cancel();
+ }
+
+ Console.CancelKeyPress += OnCancelKeyPress;
return new(new(args), cts);
</code_context>
<issue_to_address>
**issue (broader_impact):** When `AppBuilder.Create` has been called more than once in the same process, this handler removes only its own subscription; handlers from earlier builders remain subscribed and continue setting `e.Cancel = true`. Consequently, a second Ctrl+C still does not take the default termination path, so the documented force-exit escape hatch remains ineffective.
**Triggers:** When multiple `AppBuilder.Create` calls have occurred before Ctrl+C is pressed.
**Suggested fix:** Track and remove the previous application handler, or centralize the process-wide Ctrl+C subscription so only the active builder can suppress the first interrupt.
</issue_to_address>Sourcery assessment
Approval pending. 1 finding to address first.
Blocking findings: src/Spectre/CommandLine.Spectre/AppBuilder.cs:50
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
|
The issue described is correct: the current implementation of To address this, you should centralize the Ctrl+C subscription. Instead of registering a new handler in every src/Spectre/CommandLine.Spectre/AppBuilder.cs |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 25 |
| Duplication | 2 |
AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Pull Request Overview
The PR successfully wires the CancellationToken into the Spectre.Console pipeline, meeting most acceptance criteria. However, Codacy and code review findings reveal a memory leak in AppBuilder.cs because the static Console.CancelKeyPress event handler is only unsubscribed upon an interrupt. If the command finishes successfully, the handler remains attached.
There is also a gap in test coverage specifically for the handler detachment logic. While the major logic for propagation is sound, the registration should be moved to a try-finally block within the executor to ensure clean teardown. Note that the disposal of the CancellationTokenSource is explicitly deferred to a separate issue (#32).
About this PR
- As noted in the PR description, the
CancellationTokenSourceis not yet disposed. Ensure this is addressed in the follow-up issue #32 to avoid resource leaks.
Test suggestions
- Verify that CommandAppExecutor.Run passes the instance CancellationToken to the underlying ICommandApp.
- Verify that CommandAppExecutor.RunAsync passes the instance CancellationToken to the underlying ICommandApp.
- Verify that cancelling the CancellationTokenSource is observable via the token received by the command during Run.
- Verify that cancelling the CancellationTokenSource is observable via the token received by the command during RunAsync.
- Verify that the Console.CancelKeyPress handler in AppBuilder.Create detaches itself after a single execution.
Prompt proposal for missing tests
Consider implementing these tests if applicable:
1. Verify that the Console.CancelKeyPress handler in AppBuilder.Create detaches itself after a single execution.
TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback
|
Bito Automatic Review Skipped - Branch Excluded |
AppBuilder.Create built a CancellationTokenSource and cancelled it when the user pressed Ctrl+C, but nothing consumed it. CommandAppExecutor called Spectre's Run(args) and RunAsync(args), the overloads that take no token, and the source was only ever registered in the container. The single channel from that source to a command is the token parameter of ICommandApp.Run/RunAsync, and it was unused, so every command received a token that could never be cancelled and the feature was inert. Spectre.Console.Cli 0.53.1 provides Run(args, cancellationToken) and RunAsync(args, cancellationToken); the executor now uses them. The Ctrl+C handler also set e.Cancel = true unconditionally, which suppressed process termination even when the running command never observed its token -- a blocking call, or a third-party library in a tight loop -- leaving the application unkillable from the keyboard. The handler now detaches itself as it runs, so the first interrupt cancels cooperatively and a second one takes the default path and terminates. CommandAppConfigurator builds an executor too, and had no source to give it. It now takes one rather than defaulting, so an application configured through that entry point cannot silently lose cancellation. Two regression tests assert what was missing: that the token Spectre receives reports CanBeCanceled, and that cancelling the source the application was built with is observed through that token. Found by GitHub Copilot's pull request reviewer on #11. BREAKING CHANGE: CommandAppExecutor and CommandAppConfigurator take a CancellationTokenSource as a second constructor argument. Code that constructs either directly must supply one. AppBuilder already does. Refs: #14
…Source CommandAppExecutor and CommandAppConfigurator only ever read .Token, so requiring the source advertised a Cancel() capability neither type uses. Both now take a CancellationToken and AppBuilder passes cancellationTokenSource.Token. Three reasons, raised independently by two external reviewers: - The conversion only goes one way. `.Token` is free, while turning a token back into a source needs CreateLinkedTokenSource plus another disposable to own, so accepting a token accepts strictly more callers - including anyone already holding one from IHostApplicationLifetime or an outer pipeline. - Passing an IDisposable into a type that is not IDisposable raises an ownership question with no answer: the executor cannot dispose it. - The source's .Token was read at execution time, so disposing the source between construction and Run threw ObjectDisposedException. Verified: reading .Token after Dispose() throws, whereas a token captured beforehand stays usable, including for callback registration. AppBuilder still creates, cancels and DI-registers the source. A command resolving it from the container can legitimately request shutdown, so that registration is deliberately unchanged. Two further review findings on the interrupt handler are fixed here because both make claims the code already documented but did not hold: - The handler is now one-shot via Interlocked.Exchange. Unsubscribing inside the handler cannot remove the delegate from an invocation list a concurrent raise has already captured, so two interrupts dispatched together could both set e.Cancel = true and the advertised "second one terminates" needed a third press. - cts.Cancel() is wrapped. It runs consumer cancellation callbacks synchronously and wraps their exceptions in an AggregateException, which surfaced unhandled on the CancelKeyPress thread and would have terminated the process during a graceful shutdown. It is reported rather than swallowed, per the no-silent-failures rule. Adds the end-to-end test the existing coverage was missing: the previous test only asserted the source reached the container, so it passed even when the executor was handed an unrelated token. The new test drives the real Spectre pipeline and asserts on the token the command was invoked with. Confirmed by mutation - injecting CancellationToken.None at the call site fails the new test while the three older ones still pass. Build clean in Debug and Release, 0 warnings. All tests pass. BREAKING CHANGE: CommandAppExecutor and CommandAppConfigurator now take a CancellationToken instead of a CancellationTokenSource. Callers constructing either directly pass `cancellationTokenSource.Token` instead of `cancellationTokenSource`, or CancellationToken.None where cancellation is not required. Refs: #14
…rl+C Three findings from the external review panel, all on the interrupt path this PR makes reachable for the first time. A cancelled run no longer prompts for input on the way out. With PauseBeforeExit set, Run/RunAsync printed "Press Enter to exit..." and blocked on stdin even after Ctrl+C, so the shutdown the user had just requested became a hang. Only reachable once the token actually reached the running command, which is what this PR does. The interrupt handler now cancels before writing to the console. It runs on the CancelKeyPress thread, where console I/O can block or throw; writing first risked skipping the cancellation entirely after e.Cancel had already suppressed termination - leaving the application neither stopped nor killable from the keyboard. AppBuilder.Create documents the Ctrl+C contract it installs: which interrupt cancels, which terminates, that the source is resolvable from the container, and that neither the source nor the subscription is released on normal completion (#32). Adds regression cover for the pause-after-cancel hang on both Run and RunAsync. Confirmed by mutation: making the new guard unreachable at runtime fails both tests with ReadLineCount = 1. Build clean in Debug and Release, 0 warnings. 207 tests pass in the Spectre suite, up from 205. Refs: #14
1434e03 to
c0df85a
Compare
There was a problem hiding this comment.
Pull request overview
Connects application cancellation to Spectre commands and improves Ctrl+C shutdown behavior.
Changes:
- Propagates cancellation tokens through synchronous and asynchronous execution.
- Adds cooperative shutdown, forced-exit behavior, and cancellation-aware exit prompting.
- Adds regression tests and migration documentation.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/Spectre/CommandLine.Spectre/AppBuilder.cs |
Wires cancellation and handles interrupts. |
src/Spectre/CommandLine.Spectre/CommandAppExecutor.cs |
Passes tokens to Spectre. |
src/Spectre/CommandLine.Spectre/CommandAppConfigurator.cs |
Propagates tokens to executors. |
tests/Spectre/CommandLine.Spectre.Tests/AppBuilderTests.cs |
Tests builder token propagation. |
tests/Spectre/CommandLine.Spectre.Tests/CommandAppExecutorTests.cs |
Tests execution and cancelled prompting. |
tests/Spectre/CommandLine.Spectre.Tests/CommandAppConfiguratorTests.cs |
Updates configurator tests. |
change-log/14-wire-cancellation-token.md |
Documents fixes and breaking changes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Code Coverage OverviewLanguages: C# C# / code-coverage/coverletThe overall line coverage in commit f2feec6 in the Show a line coverage summary of the most impacted files.
Updated |
Three findings from the PR review, all genuine gaps rather than defects in the shipped behaviour. The interrupt contract had no coverage at all. The existing tests cancel a source directly and none of them reaches Console.CancelKeyPress, so the one-shot guard, the self-unsubscription and the disposed-source path could all regress silently. Three tests now drive the real handler: the first interrupt cancels the token and suppresses termination, a second one is not suppressed, and one arriving after the builder has been disposed is handed back to the default path. The handler is reached through the field the builder already keeps it in. Console.CancelKeyPress cannot be raised from a test and ConsoleCancelEventArgs has no public constructor - confirmed by reflecting over the type, which reports zero public constructors and one non-public one taking a ConsoleSpecialKey. The alternative the reviewer suggested, spawning a process and sending it a real interrupt, is operating-system specific and flaky under CI, and would exercise the harness as much as the handler. Confirmed by mutation: disabling the one-shot guard fails the second-interrupt test, and making the disposed path suppress the interrupt fails two of the three. CommandAppConfigurator had no test that could tell its token apart from a default one. Every existing test passed CancellationToken.None and matched It.IsAny<CancellationToken>(), so a configurator that discarded its token and built the executor with None would have passed all of them. The new test captures the token Spectre receives and asserts it observes cancellation from the source the configurator was built with. Confirmed by mutation: degrading the token to None fails the new test alone, while the other three still pass. Refs: #14 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KECFm7givf8zoQi2hJmCiN
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
change-log/14-wire-cancellation-token.md:71
- This describes a prior use-after-dispose defect that the old implementation could not have had: before this PR,
CommandAppExecutoraccepted no source or token and called Spectre's token-less overloads, so it never readsource.Tokenat execution time. Rephrase this as a general lifetime benefit of capturing a token rather than claiming it fixes observed prior behavior.
`.Token` was read at execution time rather than at construction, disposing it
in between made `Run`/`RunAsync` throw `ObjectDisposedException`; a token
captured up front stays usable after its source is disposed (#14).
The review was right that the AggregateException branch had no test: the
three CancelKeyPress tests covered normal cancellation, a second interrupt
and disposal, but none registered a callback that throws - so the branch
that exists to stop a consumer's callback killing the process was the one
path never exercised.
Verified the premise before writing the test rather than assuming it.
CancellationTokenSource.Cancel() runs registered callbacks synchronously and
wraps what they throw:
Cancel() threw AggregateException; Message contains inner text: True
inner count=1, inner[0]=InvalidOperationException
IsCancellationRequested after a throwing callback: True
so the handler's catch sees an AggregateException whose Message carries the
consumer's text, which is what the test asserts reaches the console. The same
probe confirmed Cancel() after Dispose() throws ObjectDisposedException, which
is the assumption the disposed-interrupt path rests on.
The test asserts all three guarantees: the handler does not throw, the
interrupt is still handled cooperatively rather than becoming a kill, and the
failure is reported rather than swallowed.
Refs: #14
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KECFm7givf8zoQi2hJmCiN
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/Spectre/CommandLine.Spectre/CommandAppExecutor.cs:68
- This one-time check races with the blocking
Console.ReadLine(): if the first Ctrl+C arrives after this check (or whileReadLineis waiting), the handler cancels the token and suppresses termination, but stdin remains blocked. The cancelled-run pause therefore still requires Enter or a second Ctrl+C. Make the input wait itself cancellation-aware (and treat cancellation as a normal skipped pause) rather than checking only before it starts.
if (cancellationToken.IsCancellationRequested)
Code Review Agent Run #b7fd62Actionable Suggestions - 0Review Details
Bito Usage GuideCommands Type the following command in the pull request comment and save the comment.
Refer to the documentation for additional commands. Configuration This repository uses Documentation & Help |
Changelist by BitoThis pull request implements the following key changes.
|
AppBuilder.Create subscribes the interrupt handler to the process-wide Console.CancelKeyPress event before the builder exists. If construction then threw - a services-bundle field initialiser is enough - the catch disposed the cancellation source but left the handler subscribed, so a later Ctrl+C invoked a handler holding a disposed source: it cancelled nothing and set e.Cancel = false, doing nothing while potentially interfering with other handlers in the same process. The delegate is now held in a variable declared above the try, so the catch can remove it before disposing the source. Unsubscribing first is the right order: it stops any new invocation acquiring a source that is about to go away. Found by an independent Gemini review of this branch; neither of the two other external reviewers spotted it. The defect predates this PR - it arrived with the IDisposable ownership change already on main - but this branch is where the code now lives. Not covered by a test: reaching the failure path needs a throw between the subscription and the return, and there is no seam to inject one. Recorded rather than papered over. Also in this commit: - The getting-started guide and the documentation site describe the Ctrl+C contract this branch establishes: the first interrupt cancels cooperatively, a second terminates, the token reaches the running command, and PauseBeforeExit is skipped after cancellation. None of that was written down. - The change-log claim that a failing cancellation callback "cannot turn a graceful shutdown into a crash" is narrowed to what the code actually guarantees. Reporting the failure writes to the console, which on the interrupt thread is itself best-effort. - A comment justifying the method-group unsubscription cited definite assignment, which the hoist above has just made untrue. Refs: #32 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KECFm7givf8zoQi2hJmCiN
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
change-log/14-wire-cancellation-token.md:73
- This describes an intermediate implementation rather than the released baseline: before this PR the executor called Spectre's token-less overloads, so no source token was read at execution time and disposal could not cause the stated exception. Phrase this as a consequence of accepting a source (the rejected alternative) so the changelog does not imply consumers previously had this behavior.
`.Token` was read at execution time rather than at construction, disposing it
in between made `Run`/`RunAsync` throw `ObjectDisposedException`; a token
captured up front stays usable after its source is disposed (#14).
CancellationTokenSource documents every public member as thread-safe except Dispose, which "must only be used when all other operations on the CancellationTokenSource have completed". The interrupt handler runs on the console's own thread and calls Cancel(); Dispose runs on the main thread and disposes the same source. Nothing stopped the two overlapping. Catching ObjectDisposedException only covered the case where disposal had already completed and become visible. It did not serialise an overlap inside Cancel() and Dispose(). Both now take one gate. The handler checks under it whether disposal has claimed the source and, if not, cancels while still holding it - Cancel has to stay inside because it runs consumer callbacks synchronously. Dispose marks the source released and disposes it under the same gate. System.Threading.Lock is re-entrant, verified rather than assumed, so a callback disposing the builder on the callback thread does not deadlock. The ObjectDisposedException catch is gone because the gate makes it unreachable: only Dispose releases an owned source, and never while the handler holds the gate. The ownership flag is replaced by the gate itself, which carries the same information - it exists exactly when this builder created the source. Also fixed here, found by the same review: the handler wrote e.Cancel = false on two paths. ConsoleCancelEventArgs is a single instance shared by every subscriber on a raise and the runtime reads whatever is left after the last handler returns, so writing false could override a suppression another subscriber legitimately asked for - a hosting lifetime, or a second AppBuilder. False is already the default, so the handler now only ever adds a true of its own and leaves the argument alone otherwise. Reviewed by three external models. Codex rated the race must-fix and returned REQUEST_CHANGES; Gemini and Copilot (Grok 4.6) both analysed the same race and approved, judging it unreachable in standard use because Dispose runs after Run returns. The documented contract decided it: the pairing is unsupported regardless of how narrow the window is. The maintainer chose to fix it here rather than defer. BREAKING CHANGE: none for consumers - the private constructor's shape changed, but the public constructor and Create keep their signatures. Refs: #32 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KECFm7givf8zoQi2hJmCiN
The Ctrl+C paragraph added in the previous commit landed between the Dispose bullet and the AddServicesBundle one, splitting the list into two and leaving the last member orphaned below a paragraph. Moved below the complete list, where it reads as a note on the list rather than a member of it. Refs: #32 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KECFm7givf8zoQi2hJmCiN
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
change-log/14-wire-cancellation-token.md:73
- The pre-change executor never accepted a source or read
.Token, soRun/RunAsynccould not previously fail because the source was disposed between construction and execution. Rephrase this as a design benefit of capturing the token up front rather than documenting an old failure mode that did not exist onmain.
`.Token` was read at execution time rather than at construction, disposing it
in between made `Run`/`RunAsync` throw `ObjectDisposedException`; a token
captured up front stays usable after its source is disposed (#14).
src/Spectre/CommandLine.Spectre/AppBuilder.cs:470
Lockis re-entrant, so it does not actually guarantee the stated “Dispose only after all other operations completed” rule when a cancellation callback callsbuilder.Dispose(): the callback re-enters this block and disposes the source while the outerCancel()is still executing. Track cancellation-in-progress and defer disposal untilCancel()has returned (and add a callback-disposes-builder regression test) rather than relying solely on this lock.
lock (_interruptGate.Sync)
{
_interruptGate.SourceReleased = true;
_cancellationTokenSource.Dispose();
change-log/14-wire-cancellation-token.md:51
- This release note attributes a behavior change that the previous implementation already had: its
ObjectDisposedExceptioncatch returned before the olde.Cancel = true, so a post-disposal interrupt was already unsuppressed. It also says the new code assignse.Cancel = false, whereas the implementation deliberately leaves the shared event argument unchanged. Describe the actual new disposal/cancellation serialization instead.
- An interrupt that arrives after the builder has been disposed no longer
suppresses itself. `AppBuilder` became `IDisposable` on `main` and releases the
cancellation source it owns, so `Cancel()` on the `CancelKeyPress` thread can
race disposal and throw `ObjectDisposedException`. The handler now hands that
interrupt back to the default path — `e.Cancel = false` — instead of
Code Review Agent Run #eb04ceActionable Suggestions - 0Review Details
Bito Usage GuideCommands Type the following command in the pull request comment and save the comment.
Refer to the documentation for additional commands. Configuration This repository uses Documentation & Help |
The gate added in the previous commit did not close the hole it was meant to. Holding a re-entrant lock across Cancel() cannot serialise it against a Dispose reached from inside Cancel itself: consumer cancellation callbacks run synchronously, so a callback that disposes the builder runs on the cancelling thread, re-acquires the lock it already owns, marks the source released and disposes it inside the call still unwinding. That is the exact overlap the gate exists to prevent, and re-entrancy - which the previous commit relied on to avoid a deadlock - is what let it through. Cancel now runs outside the gate, flagged as in progress under it. A Dispose arriving during that window records that a release is owed rather than performing it, and the cancelling thread disposes the source as it unwinds. Nothing runs against the source while it is being disposed, on any thread, including the cancelling one. Deferring is better than waiting in a second respect: Dispose no longer blocks behind a consumer callback, so a callback that never returns cannot hang shutdown. Adds the test for it: a cancellation callback that disposes the builder. The handler must not throw, the interrupt is still handled cooperatively, and the source must end up disposed - deferred, not skipped. Also corrects the change log, which still described the released-source path as writing e.Cancel = false. It returns without touching the shared event arguments, so the interrupt is left unsuppressed only where no other subscriber has suppressed it. Both found by the PR reviewer on the previous commit. Refs: #32 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KECFm7givf8zoQi2hJmCiN
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
change-log/14-wire-cancellation-token.md:75
- This describes a prior behavior that never existed: before this change the executor accepted only
ICommandAppand called the token-less overloads, so it did not read a source's.Tokenat execution time. It also contradicts the defect summary above (lines 4–8). Please phrase this as rationale for capturing the token up front rather than as historical behavior.
`IHostApplicationLifetime` or an outer pipeline. And because the source's
`.Token` was read at execution time rather than at construction, disposing it
in between made `Run`/`RunAsync` throw `ObjectDisposedException`; a token
captured up front stays usable after its source is disposed (#14).
Code Review Agent Run #7803a3Actionable Suggestions - 0Review Details
Bito Usage GuideCommands Type the following command in the pull request comment and save the comment.
Refer to the documentation for additional commands. Configuration This repository uses Documentation & Help |



User description
Summary
The cancellation feature did not work.
AppBuilder.Createbuilt aCancellationTokenSourceand cancelled it on Ctrl+C, but nothing consumed it — so every command received a token that could never fire.Found by GitHub Copilot's PR reviewer on #11.
Refs #14, refs #32.
The defect
CommandAppExecutorcalled the token-less Spectre overloads:Spectre.Console.Cli 0.53.1 — the version in use — provides both:
That token parameter is the only channel from the builder's source to a command.
AppBuilderregistered theCancellationTokenSourcein the container (services.AddSingleton(cancellationTokenSource)) andgrepacrosssrc/finds nothing resolving it. So Ctrl+C cancelled a source nobody read, andDoExecute/DoExecuteAsyncwere handed a default token.Combined with the unconditional
e.Cancel = true, Ctrl+C did nothing at all — it neither cancelled the command nor terminated the process.Changes
CommandAppExecutortakes aCancellationTokenand passes it toICommandApp.Run(args, token)andICommandApp.RunAsync(args, token). It also skips the"Press Enter to exit..." pause when the token is already cancelled, so a shutdown the user
just asked for does not turn into a wait on stdin.
AppBuilder.ConfigureCommandApppasses the token of the source it already owns — the valuewas in scope and unused.
AppBuilder.Creategives Ctrl+C an escape hatch. The handler is one-shot and detaches itself,so the first interrupt cancels cooperatively and a second takes the default path and terminates:
Cancelling before writing is deliberate: this runs on the
CancelKeyPressthread, where consoleI/O can block or throw, so writing first risks suppressing termination without ever cancelling.
CommandAppConfiguratorbuilds an executor too and had no token to give it. It now takes one —required rather than optional, so an application configured through that entry point cannot
silently lose cancellation.
Rebased onto the merged
mainThis branch was cut from the #11 branch, which was squash-merged, so its commits are not
ancestors of
mainand the PR showed a spurious 428-file diff. It has been rebased withgit rebase --onto origin/main f23b852; every commit builds individually.The conflict was real rather than textual.
mainnow hasAppBuilderimplementingIDisposableand owning both the cancellation source and the handler subscription. The two changes compose:
the builder releases both on
Dispose, and the handler additionally detaches itself once aninterrupt has been handled, so whichever happens first wins and removing an already-removed handler
is a no-op.
One behaviour is new to the merge and belongs to neither change alone: an interrupt arriving after
the builder has been disposed is handed back to the default path (
e.Cancel = false) rather thansuppressed. The run it exists to interrupt is already over, and a press that neither cancels nor
terminates is precisely the unkillable behaviour this change set removes.
What this does not close
#32 asked for two things, and both are now addressed — but by two different changes. The ownership
and disposal model (handler accumulation across repeated
AppBuilder.Createcalls, and disposing thesource) landed on
mainwith #11; this PR fixes the escape hatch. One residual case is inherentrather than outstanding: a caller that never disposes its builder and is never interrupted still
leaves the subscription installed for the lifetime of the process, which is what not disposing an
IDisposablemeans.Whether #32 closes on merge is the maintainer's call; this PR does not close it automatically.
Testing
Regression tests assert exactly what was absent — that the token Spectre receives is live, and that
cancelling the builder's source is observed through it:
Both
RunandRunAsyncare covered, on the executor and now on the configurator too. Added afterreview:
cancels the token and suppresses termination, a second is not suppressed, one arriving after
disposal is handed back to the default path, and a consumer callback that throws is reported
rather than allowed to escape and kill the process. The handler is reached through the field the builder keeps it in, because
Console.CancelKeyPresscannot be raised from a test andConsoleCancelEventArgshas no publicconstructor — verified by reflection: zero public constructors, one non-public taking a
ConsoleSpecialKey. Confirmed by mutation: disabling the one-shot guard fails the second-interrupttest, and making the disposed path suppress the interrupt fails two of the three.
CancellationToken.Noneand matchedIt.IsAny<CancellationToken>(), so a configurator that discarded its token would have passed all ofthem. Confirmed by mutation: degrading the token to
Nonefails the new test alone.Breaking changes
CommandAppExecutorandCommandAppConfiguratortake aCancellationTokenas a required secondconstructor argument — not a
CancellationTokenSource. Code constructing either directly passescancellationTokenSource.Token, orCancellationToken.Nonewhere cancellation is not required.A token is taken rather than the source because neither type ever calls
Cancel, and because a tokencaptured up front stays usable after its source is disposed. Recorded in
change-log/14-wire-cancellation-token.md.Related
Summary by Sourcery
Wire Ctrl+C cancellation through to running commands and preserve a force-exit path when graceful shutdown is not honored.
New Features:
Bug Fixes:
Enhancements:
Documentation:
Tests:
CodeAnt-AI Description
Connect Ctrl+C cancellation to running commands and prevent shutdown hangs
What Changed
Impact
✅ Ctrl+C stops cancellation-aware commands✅ Fewer shutdown hangs✅ Force exit available when commands ignore cancellation💡 Usage Guide
Checking Your Pull Request
Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.
Talking to CodeAnt AI
Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:
This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.
Example
Preserve Org Learnings with CodeAnt
You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:
This helps CodeAnt AI learn and adapt to your team's coding style and standards.
Example
Retrigger review
Ask CodeAnt AI to review the PR again, by typing:
Check Your Repository Health
To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.
Summary by CodeRabbit
Summary by Bito
This change hardens AppBuilder's Ctrl+C cancellation lifecycle by preventing disposal from overlapping an active CancellationTokenSource.Cancel() call. It defers source release when disposal occurs inside a synchronous cancellation callback, preserves cooperative interrupt handling, and adds regression coverage and documentation for the race.
Detailed Changes