Skip to content

Cancel the async action's token off the caller's thread in Stop [patch] - #110

Merged
matt-edmondson merged 2 commits into
mainfrom
fix/stop-cancels-off-thread-83
Nov 13, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
fix/stop-cancels-off-thread-83

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #83

What was wrong

StopCore called ActionCancellation.Cancel() inline. CancellationTokenSource.Cancel() runs the token's callbacks, and any continuations they complete, on the thread that cancels. With an AsyncAction that waits through ct.Register(() => tcs.TrySetCanceled()), the action's catch/finally cleanup therefore ran inside Stop():

  • Stop() was blocked for as long as the cleanup took (2 s in the report).
  • If the cleanup marshalled back to the UI thread, it deadlocked.
  • A callback that threw escaped Stop() (and StopAsync()/Dispose()) as an AggregateException.

Change

  • StopCore hands the cancel to the thread pool (CancelAction), still outside the lock. It keeps the ObjectDisposedException guard for a run that finishes first.
  • A callback's exception goes into CancellationFailure. RethrowExceptions reports it when the run itself has nothing to report, and a restart clears it the same way it discards a failed run's exception.
  • StopAsync also awaits the handed-off cancel, so a callback failure has been recorded by the time it returns.
  • The stop-ordering guarantees are unchanged. StopGeneration is still bumped synchronously under the lock, so a dispatched run that has not begun is skipped (The action can still start after Stop() returns and PollingTask completes, and there is no public way to wait for it #67). StopAsync still waits for the loop and the run.
  • The XML docs, the README Stop row and CLAUDE.md now describe this.

Tests

  • StopDoesNotRunTheAsyncActionCleanupOnTheCallersThread: the repro from the issue, with Stop() called from a dedicated thread the way a UI thread would call it. Without the fix, Stop() takes the full 10 s of cleanup and fails the < 5 s bound.
  • AThrowingCancellationCallbackIsReportedRatherThanThrownFromStop: without the fix, Stop() throws AggregateException(InvalidOperationException: callback failed). With it, neither stop throws and RethrowExceptions reports the exception.

I checked both tests against main's IntervalAction.cs and both fail there. With the fix, the full suite passes (64/64, net10.0), and a Release build across all target frameworks succeeds with no warnings.

This PR and #109 both touch IntervalAction.cs and the CLAUDE.md bullet list. They're independent, so whichever lands second may need a trivial rebase.


Generated by Claude Code

Stop() cancelled the run's token inline, and CancellationTokenSource
runs token callbacks, and the continuations they complete, on the
cancelling thread. An action waiting through ct.Register therefore ran
its cleanup inside Stop(), blocking the caller (2 s in the report) or
deadlocking a UI thread the cleanup marshalled back to, and a callback
that threw escaped Stop() as an AggregateException.

The cancel now runs on the thread pool. A throwing callback is kept for
RethrowExceptions, which a restart clears like a failed run, and
StopAsync waits for the cancel so the failure is recorded when it
returns.

Fixes #83

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017vfJW59k83EYpQhgtoH5Qc
… check [patch]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017vfJW59k83EYpQhgtoH5Qc

Copy link
Copy Markdown
Contributor Author

CI note: Test on macos-latest failed on 275027d in ShortActionIntervalKeepsToThePollingGridInFromLastCompletion (200,500). That's an existing timing test, and this PR doesn't touch the polling or wait code it exercises.

  • The library code at 275027d is identical to the previous head, a6b21b0, whose macOS job passed. The only change since then is the 2-line null-check in a new test (git diff a6b21b0 275027d --stat → IntervalActionTests.cs | 3 ++-).
  • The gaps between runs were 1035, 1301, 1004, 1144, 1003, 1119 and 1107 ms against a 1000 ms interval. The test guards against slipping by the run time each cycle, which would put every gap near 1500 ms. That didn't happen here. One timer wake-up came about 300 ms late, which is more than the 100–150 ms of oversleep the test's 1.25× bound (1250 ms) allows for on macOS runners.

I'll re-run the failed job once when the rest of the run finishes. If it fails the same way again, the bound needs a separate fix on main. The test could assert on the median gap rather than the maximum, which would still catch a per-cycle slip. That change belongs in its own PR, not this one.


Generated by Claude Code

@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants