Skip to content

fix(spectre)!: Connect the cancellation token to the running command - #42

Merged
kploch merged 9 commits into
mainfrom
fix/14-wire-cancellation-token
Aug 28, 2026
Merged

fix(spectre)!: Connect the cancellation token to the running command#42
kploch merged 9 commits into
mainfrom
fix/14-wire-cancellation-token

Conversation

@kploch

@kploch kploch commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

User description

Summary

The cancellation feature did not work. AppBuilder.Create built a CancellationTokenSource and 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

CommandAppExecutor called the token-less Spectre overloads:

var result = commandApp.Run(args);
var result = await commandApp.RunAsync(args).ConfigureAwait(false);

Spectre.Console.Cli 0.53.1 — the version in use — provides both:

ICommandApp.Run(IEnumerable<string>, CancellationToken)
ICommandApp.RunAsync(IEnumerable<string>, CancellationToken)

That token parameter is the only channel from the builder's source to a command. AppBuilder registered the CancellationTokenSource in the container (services.AddSingleton(cancellationTokenSource)) and grep across src/ finds nothing resolving it. So Ctrl+C cancelled a source nobody read, and DoExecute/DoExecuteAsync were 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

CommandAppExecutor takes a CancellationToken and passes it to
ICommandApp.Run(args, token) and ICommandApp.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.ConfigureCommandApp passes the token of the source it already owns — the value
was in scope and unused.

AppBuilder.Create gives 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:

void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
{
    if (Interlocked.Exchange(ref interruptHandled, 1) != 0)
    {
        e.Cancel = false;

        return;
    }

    Console.CancelKeyPress -= OnCancelKeyPress;
    e.Cancel = true;

    try
    {
        cancellationTokenSource.Cancel();
    }
    catch (ObjectDisposedException)
    {
        e.Cancel = false;

        return;
    }
    catch (AggregateException exception)
    {
        AnsiConsole.WriteLine($"A cancellation callback failed during shutdown: {exception.Message}");
    }

    AnsiConsole.WriteLine("Shutting down... press Ctrl+C again to force an exit.");
}

Cancelling before writing is deliberate: this runs on the CancelKeyPress thread, where console
I/O can block or throw, so writing first risks suppressing termination without ever cancelling.

CommandAppConfigurator builds 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 main

This branch was cut from the #11 branch, which was squash-merged, so its commits are not
ancestors of main and the PR showed a spurious 428-file diff. It has been rebased with
git rebase --onto origin/main f23b852; every commit builds individually.

The conflict was real rather than textual. main now has AppBuilder implementing IDisposable
and 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 an
interrupt 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 than
suppressed. 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.Create calls, and disposing the
source) landed on main with #11; this PR fixes the escape hatch. One residual case is inherent
rather 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
IDisposable means.

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:

received.CanBeCanceled.Should().BeTrue("a token that can never be cancelled makes the whole feature inert");
cancellationTokenSource.Cancel();
received.IsCancellationRequested.Should().BeTrue("cancelling the source the application was built with must reach the running command");

Both Run and RunAsync are covered, on the executor and now on the configurator too. Added after
review:

  • The Ctrl+C contract. Four tests drive the real handler, one per branch: the first interrupt
    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.CancelKeyPress cannot be raised from a test and ConsoleCancelEventArgs has no public
    constructor — verified by reflection: zero public constructors, one non-public taking a
    ConsoleSpecialKey. 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.
  • The configurator's token. Every previous test passed CancellationToken.None and matched
    It.IsAny<CancellationToken>(), so a configurator that discarded its token would have passed all of
    them. Confirmed by mutation: degrading the token to None fails the new test alone.
dotnet build -c Debug   ->  0 Warning(s), 0 Error(s)   (every commit on the branch)
dotnet test  -c Debug   ->  232 passed, 0 failed  (Ploch.CommandLine.Spectre.Tests)
                            all 8 assemblies pass

Breaking changes

CommandAppExecutor and CommandAppConfigurator take a CancellationToken as a required second
constructor argument — not a CancellationTokenSource. Code constructing either directly passes
cancellationTokenSource.Token, or CancellationToken.None where cancellation is not required.
A token is taken rather than the source because neither type ever calls Cancel, and because a token
captured 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:

  • Connect application cancellation tokens to synchronous and asynchronous Spectre command execution.
  • Allow a first Ctrl+C to request graceful shutdown and a subsequent Ctrl+C to force termination.

Bug Fixes:

  • Prevent cancelled runs from blocking on the exit prompt.
  • Handle cancellation races and callback failures without leaving the process unresponsive or crashing the cancellation thread.

Enhancements:

  • Require cancellation tokens when constructing command executors and configurators so cancellation cannot be silently dropped.

Documentation:

  • Document the cancellation-token wiring and behavior in the change log.

Tests:

  • Add regression coverage for token propagation, cancellation behavior, Ctrl+C handling, disposal races, callback failures, and cancelled-run shutdown behavior.

CodeAnt-AI Description

Connect Ctrl+C cancellation to running commands and prevent shutdown hangs

What Changed

  • Commands now receive the application's cancellable token for both synchronous and asynchronous runs, so Ctrl+C can stop commands that honor cancellation.
  • The first Ctrl+C requests a graceful shutdown; a second Ctrl+C can force the process to exit if the command does not stop.
  • Cancelled runs no longer wait for the “Press Enter to exit...” prompt.
  • Cancellation callback failures are reported without crashing the shutdown thread.
  • Direct users of the executor and configurator must now provide a cancellation token.

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:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

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:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

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

  • New Features
    • Ctrl+C now supports cooperative cancellation: the first press cancels the running command, while a second press forcefully terminates the process.
    • Cancellation is consistently propagated to commands, enabling responsive shutdown.
  • Bug Fixes
    • Cancellation handlers now clean up safely and report callback errors without disrupting the application.
    • Cancelled runs no longer display or wait for the “Press Enter to exit…” prompt.
  • Documentation
    • Updated getting-started, builder, and changelog documentation with the new cancellation behavior.

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
  • AppBuilder introduces CancelInProgress and DisposeDeferred state in InterruptGate so cancellation callbacks can safely dispose the builder without releasing the CancellationTokenSource while Cancel() is still unwinding, in AppBuilder.cs.
  • Cancellation now executes outside the lifetime lock, while deferred release is completed in the cancellation handler's finally block; callback failures are still reported after the interrupt is marked handled, in AppBuilder.cs.
  • Adds a regression test that disposes AppBuilder from a cancellation callback and verifies that cancellation completes without throwing and that the source is disposed afterward, in AppBuilderTests.cs.
  • Updates the documented post-disposal interrupt behavior to explain why the handler leaves shared event arguments unchanged rather than explicitly assigning e.Cancel = false, in 14-wire-cancellation-token.md.

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@codeant-ai

codeant-ai Bot commented Aug 23, 2026

Copy link
Copy Markdown

🤖 CodeAnt AI — Review Status

Status Commit Started (UTC) Finished (UTC)
✅ Incremental review completed c0df85a Aug 28, 2026 · 11:58 11:59
✅ Incremental review completed 9d87ac7 Aug 26, 2026 · 15:34 15:35
✅ Reviewed your PR c487a60 Aug 23, 2026 · 23:20 23:23

@sourcery-ai

sourcery-ai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Reviewer's Guide

Connects 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 execution

sequenceDiagram
    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
Loading

Sequence diagram for the Ctrl+C force-exit escape hatch

sequenceDiagram
    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
Loading

File-Level Changes

Change Details Files
Wire the CancellationTokenSource created by AppBuilder into command execution and configurator paths so Spectre commands receive a live, cancellable token.
  • AppBuilder.Create now installs a self-detaching Console.CancelKeyPress handler that cancels its CancellationTokenSource on first Ctrl+C and allows the process to terminate on subsequent interrupts.
  • AppBuilder.ConfigureCommandApp constructs CommandAppExecutor with the in-scope CancellationTokenSource instead of omitting it.
  • CommandAppConfigurator requires a CancellationTokenSource in its constructor and passes it to CommandAppExecutor during Configure, preventing configurations without cancellation.
src/Spectre/CommandLine.Spectre/AppBuilder.cs
src/Spectre/CommandLine.Spectre/CommandAppConfigurator.cs
Update CommandAppExecutor to use Spectre’s token-accepting Run/RunAsync overloads so commands are cancellable via the builder’s CancellationTokenSource.
  • CommandAppExecutor’s constructor signature now includes a required CancellationTokenSource parameter documented as the source cancelled by AppBuilder.Create on Ctrl+C.
  • Run now calls ICommandApp.Run(args, cancellationTokenSource.Token) instead of the overload without a token.
  • RunAsync now calls ICommandApp.RunAsync(args, cancellationTokenSource.Token) instead of the overload without a token.
src/Spectre/CommandLine.Spectre/CommandAppExecutor.cs
Adjust unit tests to reflect the new cancellation wiring and constructor requirements, and add regression tests asserting that Spectre receives the live token.
  • CommandAppConfiguratorTests now construct CommandAppConfigurator with a CancellationTokenSource and stub/verify the token-accepting ICommandApp.Run overload.
  • CommandAppExecutorTests now construct CommandAppExecutor with a CancellationTokenSource and stub/verify the token-accepting Run/RunAsync overloads.
  • New tests in CommandAppExecutorTests assert that Spectre receives a cancellable token derived from the provided CancellationTokenSource and that cancelling the source propagates to the token (for both Run and RunAsync).
tests/Spectre/CommandLine.Spectre.Tests/CommandAppConfiguratorTests.cs
tests/Spectre/CommandLine.Spectre.Tests/CommandAppExecutorTests.cs
Document the behavioral and breaking changes to cancellation wiring and Ctrl+C handling.
  • Added change-log entry explaining that CommandAppExecutor now passes the CancellationTokenSource’s token to Spectre’s Run/RunAsync, fixing previously inert cancellation.
  • Documented the revised Ctrl+C handler behavior (first interrupt cancels, second terminates) and the breaking change that CommandAppExecutor and CommandAppConfigurator now require a CancellationTokenSource constructor argument.
change-log/14-wire-cancellation-token.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 82bed3b6-bff2-490d-ad68-da48235b1b29

📥 Commits

Reviewing files that changed from the base of the PR and between 4d4a4b4 and f2feec6.

📒 Files selected for processing (4)
  • DocumentationSite/index.md
  • change-log/14-wire-cancellation-token.md
  • src/Spectre/CommandLine.Spectre/AppBuilder.cs
  • tests/Spectre/CommandLine.Spectre.Tests/AppBuilderTests.cs
🚧 Files skipped from review as they are similar to previous changes (2)
  • change-log/14-wire-cancellation-token.md
  • DocumentationSite/index.md

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change wires the AppBuilder cancellation token through command execution. Ctrl+C now performs cooperative cancellation on the first press and uses default termination on later presses. Cancelled runs skip the exit pause. Documentation and tests cover propagation, disposal, and callback failures.

Changes

Cancellation token flow

Layer / File(s) Summary
Ctrl+C lifecycle and disposal
src/Spectre/CommandLine.Spectre/AppBuilder.cs, tests/Spectre/CommandLine.Spectre.Tests/AppBuilderTests.cs, docs/GETTING_STARTED.md, change-log/14-wire-cancellation-token.md
AppBuilder uses InterruptGate to coordinate cancellation, disposal, callback failures, and later default interrupt handling. Tests and documentation cover these behaviors.
Token propagation through execution
src/Spectre/CommandLine.Spectre/CommandAppConfigurator.cs, src/Spectre/CommandLine.Spectre/CommandAppExecutor.cs, src/Spectre/CommandLine.Spectre/AppBuilder.cs, tests/Spectre/CommandLine.Spectre.Tests/*, DocumentationSite/index.md, docs/GETTING_STARTED.md, change-log/14-wire-cancellation-token.md
CommandAppConfigurator and CommandAppExecutor now require and forward a CancellationToken to synchronous and asynchronous commands. Cancelled runs skip the exit prompt. Tests verify propagation and pause behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to f2fee

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)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning 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: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: wiring the cancellation token to the running Spectre command.
Description check ✅ Passed 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 …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

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 Coverage

Explanation

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.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/14-wire-cancellation-token

Comment @coderabbitai help to get the list of available commands.

@bito-code-review

Copy link
Copy Markdown

Bito Automatic Review Skipped - Branch Excluded

Bito didn't auto-review because the source or target branch is excluded from automatic reviews.
No action is needed if you didn't intend for the agent to review it. Otherwise, to manually trigger a review, type /review in a comment and save.
You can change the branch exclusion settings here, or contact your Bito workspace admin at kris@ploch.dev.

@codeant-ai codeant-ai Bot added the size:L This PR changes 100-499 lines, ignoring generated files label Aug 23, 2026

@sourcery-ai sourcery-ai 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.

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


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/Spectre/CommandLine.Spectre/AppBuilder.cs Outdated
@bito-code-review

Copy link
Copy Markdown

The issue described is correct: the current implementation of OnCancelKeyPress in AppBuilder.cs only detaches itself from the Console.CancelKeyPress event. If AppBuilder.Create is called multiple times, each builder registers its own OnCancelKeyPress handler. When Ctrl+C is pressed, all registered handlers execute, but each only removes itself, leaving handlers from previous builders still subscribed and continuing to suppress termination.

To address this, you should centralize the Ctrl+C subscription. Instead of registering a new handler in every AppBuilder.Create call, use a static manager or a shared state that tracks the active CancellationTokenSource and ensures only one handler is active at a time, or that the handler can identify and cancel the correct, currently active builder's token.

src/Spectre/CommandLine.Spectre/AppBuilder.cs

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;

@codacy-production

codacy-production Bot commented Aug 23, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 25 complexity · 2 duplication

Metric Results
Complexity 25
Duplication 2

View in Codacy

AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.

Run reviewer

TIP This summary will be updated as you push new changes.

@codacy-production codacy-production 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.

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 CancellationTokenSource is 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

Comment thread src/Spectre/CommandLine.Spectre/AppBuilder.cs Outdated
Comment thread src/Spectre/CommandLine.Spectre/CommandAppExecutor.cs Outdated
@codeant-ai codeant-ai Bot added size:L This PR changes 100-499 lines, ignoring generated files and removed size:L This PR changes 100-499 lines, ignoring generated files labels Aug 26, 2026
@bito-code-review

Copy link
Copy Markdown

Bito Automatic Review Skipped - Branch Excluded

Bito didn't auto-review because the source or target branch is excluded from automatic reviews.
No action is needed if you didn't intend for the agent to review it. Otherwise, to manually trigger a review, type /review in a comment and save.
You can change the branch exclusion settings here, or contact your Bito workspace admin at kris@ploch.dev.

Base automatically changed from #3-spectre-console-initial to main August 28, 2026 11:38
kploch added 3 commits August 28, 2026 13:56
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
Copilot AI balanced review requested due to automatic review settings August 28, 2026 11:58
@kploch
kploch force-pushed the fix/14-wire-cancellation-token branch from 1434e03 to c0df85a Compare August 28, 2026 11:58
@codeant-ai codeant-ai Bot added size:L This PR changes 100-499 lines, ignoring generated files and removed size:L This PR changes 100-499 lines, ignoring generated files labels Aug 28, 2026

Copilot AI 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.

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.

Comment thread src/Spectre/CommandLine.Spectre/AppBuilder.cs
Comment thread src/Spectre/CommandLine.Spectre/CommandAppConfigurator.cs
Comment thread src/Spectre/CommandLine.Spectre/CommandAppExecutor.cs
@github-code-quality

github-code-quality Bot commented Aug 28, 2026

Copy link
Copy Markdown

Code Coverage Overview

Languages: C#

C# / code-coverage/coverlet

The overall line coverage in commit f2feec6 in the fix/14-wire-cancella... branch is 98%. The line coverage in commit d73d3d9 in the main branch is 99%.

Show a line coverage summary of the most impacted files.
File main d73d3d9 fix/14-wire-cancella... f2feec6 +/-
/home/runner/wo...e/AppBuilder.cs 97% 95% -2%
/home/runner/wo...dAppExecutor.cs 100% 100% 0%

Updated August 28, 2026 14:33 UTC

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
Copilot AI review requested due to automatic review settings August 28, 2026 12:25

Copilot AI 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.

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, CommandAppExecutor accepted no source or token and called Spectre's token-less overloads, so it never read source.Token at 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).

Comment thread src/Spectre/CommandLine.Spectre/AppBuilder.cs Outdated
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
Copilot AI review requested due to automatic review settings August 28, 2026 12:43

Copilot AI 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.

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 while ReadLine is 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)

@bito-code-review

bito-code-review Bot commented Aug 28, 2026

Copy link
Copy Markdown

Code Review Agent Run #b7fd62

Actionable Suggestions - 0
Review Details
  • Files reviewed - 6 · Commit Range: f644c97..c8c083c
    • src/Spectre/CommandLine.Spectre/AppBuilder.cs
    • src/Spectre/CommandLine.Spectre/CommandAppConfigurator.cs
    • src/Spectre/CommandLine.Spectre/CommandAppExecutor.cs
    • tests/Spectre/CommandLine.Spectre.Tests/AppBuilderTests.cs
    • tests/Spectre/CommandLine.Spectre.Tests/CommandAppConfiguratorTests.cs
    • tests/Spectre/CommandLine.Spectre.Tests/CommandAppExecutorTests.cs
  • Files skipped - 0
  • Tools
    • Copy/Paste Detector (Copy/Paste Detector) - ✔︎ Successful
    • Secret Scanner (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Default Agent You can customize the agent settings here

Documentation & Help

AI Code Review powered by Bito Logo

@bito-code-review

Copy link
Copy Markdown

Changelist by Bito

This pull request implements the following key changes.

Key Change Files Impacted Summary
Bug Fix - Cancellation Token Propagation and Ctrl+C Shutdown Handling
Connects the application cancellation token to synchronous and asynchronous Spectre command execution, and updates Ctrl+C handling so the first interrupt requests cooperative shutdown while a subsequent interrupt can force termination. Cancelled runs also skip the exit prompt, and cancellation races or callback failures are handled explicitly.
Testing - Cancellation and Shutdown Regression Coverage
Adds coverage for token propagation through the builder, configurator, and executor, cancelled-run prompt suppression, one-shot Ctrl+C behavior, post-disposal interrupts, and cancellation callback failures.
Documentation - Cancellation Token Wiring Documentation
Documents the cancellation-token plumbing, Ctrl+C shutdown behavior, race handling, callback error reporting, cancelled-run behavior, and the breaking constructor changes requiring CancellationToken values.

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
Copilot AI review requested due to automatic review settings August 28, 2026 13:25

Copilot AI 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.

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).

Comment thread src/Spectre/CommandLine.Spectre/AppBuilder.cs Outdated
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
Copilot AI review requested due to automatic review settings August 28, 2026 14:14

Copilot AI 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.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comment thread src/Spectre/CommandLine.Spectre/AppBuilder.cs Outdated
Comment thread change-log/14-wire-cancellation-token.md Outdated
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
Copilot AI review requested due to automatic review settings August 28, 2026 14:18

Copilot AI 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.

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, so Run/RunAsync could 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 on main.
  `.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

  • Lock is re-entrant, so it does not actually guarantee the stated “Dispose only after all other operations completed” rule when a cancellation callback calls builder.Dispose(): the callback re-enters this block and disposes the source while the outer Cancel() is still executing. Track cancellation-in-progress and defer disposal until Cancel() 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 ObjectDisposedException catch returned before the old e.Cancel = true, so a post-disposal interrupt was already unsuppressed. It also says the new code assigns e.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

@bito-code-review

bito-code-review Bot commented Aug 28, 2026

Copy link
Copy Markdown

Code Review Agent Run #eb04ce

Actionable Suggestions - 0
Review Details
  • Files reviewed - 1 · Commit Range: f644c97..25757de
    • src/Spectre/CommandLine.Spectre/AppBuilder.cs
  • Files skipped - 0
  • Tools
    • Copy/Paste Detector (Copy/Paste Detector) - ✔︎ Successful
    • Secret Scanner (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Default Agent You can customize the agent settings here

Documentation & Help

AI Code Review powered by Bito Logo

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
Copilot AI review requested due to automatic review settings August 28, 2026 14:30
@sonarqubecloud

Copy link
Copy Markdown

Copilot AI 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.

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 ICommandApp and called the token-less overloads, so it did not read a source's .Token at 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).

@bito-code-review

bito-code-review Bot commented Aug 28, 2026

Copy link
Copy Markdown

Code Review Agent Run #7803a3

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: f644c97..f2feec6
    • src/Spectre/CommandLine.Spectre/AppBuilder.cs
    • tests/Spectre/CommandLine.Spectre.Tests/AppBuilderTests.cs
  • Files skipped - 0
  • Tools
    • Copy/Paste Detector (Copy/Paste Detector) - ✔︎ Successful
    • Secret Scanner (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Default Agent You can customize the agent settings here

Documentation & Help

AI Code Review powered by Bito Logo

@kploch
kploch merged commit 8380464 into main Aug 28, 2026
12 checks passed
@kploch
kploch deleted the fix/14-wire-cancellation-token branch August 28, 2026 22:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants