Skip to content

Fix forEach(cancelPrevious:) leaving its in-flight body running after cancel - #87

Merged
mansbernhardt merged 2 commits into
mainfrom
claude/investigate-foreach-cancelprevious
Sep 26, 2026
Merged

mansbernhardt merged 2 commits into
mainfrom
claude/investigate-foreach-cancelprevious

Conversation

@mansbernhardt

Copy link
Copy Markdown
Collaborator

Fixes the intermittent InheritCancellationContextTests.testForEachCancelPreviousInheritsContext failure (CI runs 36043894226 and 35728035828, and PR #86's first macOS serial run). The flake was a real cancellation bug.

Bug

A child task is spawned first and keyed into its parent's cancellation context afterwards (inheritCancellationContext()), so the parent's context can be cancelled in between. Its cancelAll(for: key) has then already run and never reaches the child, which keeps running with nothing left to cancel it.

  • In forEach(cancelPrevious: true), if subscription.cancel() lands right after a body starts, the outer loop is cancelled but the body is not. The loop then waits for the body, which runs to completion; in the test that's a 30 s sleep.
  • The same hole applies to any task { … }.inheritCancellationContext() inside a task whose context was just cancelled.

Evidence

  • On main, the test fails 7 times in 10 when run alone locally. Failing runs time out at exactly the waitUntil budget, because cancellation never arrives.
  • With a temporary sleep widening the window between spawn and keying, it fails every run. That's the CI signature, which confirms the mechanism.
  • After the fix: 20/20 alone, and 5/5 with the widened window.

Fix

  • One-shot contexts (the anonymous cancellationContext { } that wraps every node.task) now use a ContextToken key that stays cancelled once cancelled.
  • cancelAll(for:) closes the token in the same critical section that snapshots its registrations. Keying into a closed context, or registering inside one, takes the same lock and cancels immediately. So a racing registration either lands in the snapshot or sees the context closed, never neither.
  • User keys (cancellationContext(for:), cancel(for:), cancelInFlight) are reusable by design and unchanged.
  • An earlier attempt (checking Task.isCancelled in inheritCancellationContext()) left a residual window between the snapshot and the parent's Task.cancel(): still 1 in 10 failing. That's why the check is linearized under the store lock instead.

Tests

CancelledContextKeyingTests:

  • Public API: a parent whose context was cancelled keys a child in with inheritCancellationContext(), and the child is cancelled at once. Fails deterministically without the fix.
  • Cancellations level: registering under a closed context, and keying into one late, both cancel immediately.
  • User keys: stay reusable after cancelAll.

Full suite: 3 local runs in parallel and 1 serial, all passing, with no compiler warnings. CHANGELOG entry added under [Unreleased].

🤖 Generated with Claude Code

mansbernhardt and others added 2 commits September 25, 2026 21:13
… cancel

A child is spawned first and keyed into its parent's cancellation context
afterwards (inheritCancellationContext), so the parent's context could be
cancelled in between: its keyed cancel had already run and never reached
the child. forEach(cancelPrevious:) hit this when subscription.cancel()
landed right after a body started, so the body ran to completion. That's
the intermittent testForEachCancelPreviousInheritsContext timeout (7 in 10
failed locally when run alone).

One-shot contexts (anonymous cancellationContext, which wraps every
node.task) now use a ContextToken key that stays cancelled. Closing it
and snapshotting its registrations share one critical section with
registration, so a racing registration either lands in the snapshot or
sees the context closed. User keys stay reusable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…reach-cancelprevious

# Conflicts:
#	CHANGELOG.md
@mansbernhardt
mansbernhardt merged commit 1cc5667 into main Sep 26, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant