Skip to content

Fix generation races in SharedTaskRaceGuard#148

Draft
ruccho wants to merge 1 commit into
mainfrom
fix/shared-task-race-guard
Draft

Fix generation races in SharedTaskRaceGuard#148
ruccho wants to merge 1 commit into
mainfrom
fix/shared-task-race-guard

Conversation

@ruccho

@ruccho ruccho commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

SharedTaskRaceGuard had two generation (SelfVersion) races. Because the Source pool is static and shared across all guards, a recycled Source can be corrupted across guards.

Bug 1: The operation-side continuation calls Unlink(source) unconditionally before the CAS-based winner check on SelfVersion. If the shared-task side wins first, the Source is returned to the pool, another guard rents and re-arms it, and then the stale continuation fires — removing the re-armed Source from the new guard's linked list without holding the new guard's _gate. The Source is then missed by the new guard's drain, and the caller awaiting its PushAsync never completes (CompleteAsync / StopAndExportAsync hang).

Bug 2 (found while fixing): TryCompleteFromSharedTask acquires its CAS baseline via a plain read of SelfVersion at call time — a TOCTOU. If the operation side wins the CAS and calls SetResult after the drain snapshots but before it completes, the drain claims the next generation incorrectly and double-completes (SignalCompletion throws InvalidOperationException → crash).

Fix

Unified both sides to touch the Source only when they win the claim via a CAS from the same generation baseline captured at arm time.

  • Operation side: observe result → CAS the generation → only if won, Unlink → SetResult/SetException (winning the CAS implies no recycle happened, so Unlink is safe). If it loses, do nothing.
  • Drain side: add armVersion to Source (written under _gate), and claim each node under the _gate lock via a CAS on the armVersion baseline. Only won nodes complete after the lock is released.
  • TryCompleteFromSharedTask now takes the already-captured selfVersion as an argument.

Verification

  • A console harness compiling the real Runtime source:
    • Deterministic reproduction: pre-fix reproduces the bug (hang), post-fix OK.
    • Multithreaded stress: pre-fix crashed with InvalidOperationException within seconds; post-fix ran 30M rounds over 60s + 16M rounds over 30s with hangs=0, faults=0.
  • Traced all scenarios by hand (operation wins / shared wins then operation completes later / Source already re-rented / Unlink concurrent with Link / drain concurrent with operation completion); no breakage.
  • Unity Play Mode: ~183s record → export completed with zero errors.

🤖 Generated with Claude Code

the operation continuation unconditionally unlinked its source before
the SelfVersion claim. when the shared task won first, the pooled
source could be re-rented and re-armed by another guard, and the stale
unlink then removed it from the new guard's list without taking that
guard's gate, so the new guard's drain never completed the race and
its awaiter hung forever (e.g. CompleteAsync/StopAndExportAsync).

the shared-task drain also claimed sources by re-reading SelfVersion
at completion time, racing the operation continuation's claim from the
arm-time baseline: it could observe the already-bumped version, claim
the next generation and double-complete the source
(InvalidOperationException in ManualResetValueTaskSourceCore).

now both sides claim a generation with a CAS from the same arm-time
baseline before touching anything: the operation continuation unlinks
only after winning its claim (a won claim implies the source was never
recycled), and the drain claims each node under _gate using the
recorded arm version and completes only the nodes it won.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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