Skip to content

Hold a rewrite recording per flow instead of per thread - #863

Open
Rafael-SOWNet wants to merge 1 commit into
masterfrom
fix/rewriterecording-asynclocal
Open

Hold a rewrite recording per flow instead of per thread#863
Rafael-SOWNet wants to merge 1 commit into
masterfrom
fix/rewriterecording-asynclocal

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

Closes #859.

RewriteRecording held its ambient scope in a [ThreadStatic] field, and documented the consequence rather than fixing it:

A synchronous scope, and it has to be. Do not await inside one.

It no longer has to be. The scope is an AsyncLocal, as MathS.Settings now is (#857) and as the cancellation token in MathS.Multithreading already was.

Why this is more than a field swap

The trap #857 ran into applies directly here. An AsyncLocal flows the reference, so once the pointer reaches child tasks, two of them can report to one recording at the same time — and the steps were accumulating into a List<RewriteStep>. That is a torn write, not a merged list.

So alongside the field:

  • the store is a ConcurrentQueue<RewriteStep>;
  • closed is volatile, since it is now read from flows other than the one that set it;
  • Steps copies out rather than handing back a live view.

Behaviour

was is
a recording across an await lost, and the thread could collect a stranger's rewrites kept
work started under a recording, on another thread not collected collected
two flows each with their own recording separate separate
a recording opened inside a task, seen after it ends no no

One existing test is rewritten, not deleted

ARecordingOnOneThreadDoesNotSeeAnother started a thread inside an open recording and asserted its work was not collected. ExecutionContext flows to a manually started thread, so that work is now collected — which is the point of the change rather than a regression of it.

It becomes WorkStartedUnderARecordingIsCollectedWhereverItRuns, and the isolation it was really reaching for — that a parallel caller records its own work and nobody else's — is covered properly by SiblingRecordingsDoNotSeeEachOther, with a barrier forcing both recordings open before either does any work.

Also new: ARecordingSurvivesAnAwait (the regression) and ARecordingOpenedInsideATaskDoesNotEscapeIt.

AClosedRecordingIgnoresWhateverItIsStillHanded passes unchanged.

Off is still free

RewriteAllocationTest passes untouched: no recording open still costs one ambient read per rule set and allocates nothing, which is what #746 asks of every layer above the tree.

Verification

  • 6064 C# tests pass, 0 failed
  • 130 F# wrapper tests pass

Breaking, so it wants the 2.0 window; recorded in BREAKING-CHANGES.md with the migration, the Steps-is-now-a-snapshot note, and the fact that step order across parallel work is not defined.

Note on merge order: this touches BREAKING-CHANGES.md at the same anchors as #853 and #857. Whichever merges later needs a trivial "keep both" resolution in that file.

🤖 Generated with Claude Code

@Rafael-SOWNet
Rafael-SOWNet force-pushed the fix/rewriterecording-asynclocal branch from c402607 to 48160f2 Compare August 10, 2026 02:22
Closes #859.

RewriteRecording kept its ambient scope in a [ThreadStatic] field and documented the
consequence rather than fixing it -- "A synchronous scope, and it has to be. Do not
await inside one." It no longer has to be. The scope is an AsyncLocal, as MathS.Settings
now is and as the cancellation token in MathS.Multithreading already was.

The trap #857 flagged applies here and is why this is more than a field swap. An
AsyncLocal flows the reference, so once the pointer reaches child tasks two of them can
report to one recording at once, and the steps were accumulating into a List. That is a
torn write, not a merged list. The store is a ConcurrentQueue, `closed` is volatile
since it is now read from flows other than the one that set it, and Steps copies out
rather than handing back a live view.

One existing test encoded the old semantics and is rewritten rather than deleted:
ARecordingOnOneThreadDoesNotSeeAnother started a thread inside an open recording and
asserted its work was not collected. ExecutionContext flows to a manually started thread,
so that work is now collected -- which is the point of the change, not a regression of
it. It becomes WorkStartedUnderARecordingIsCollectedWhereverItRuns, and the isolation it
was really reaching for is covered by SiblingRecordingsDoNotSeeEachOther.

RewriteAllocationTest still passes untouched, so being off is still free: one ambient
read per rule set, nothing allocated, which is what #746 asks of every layer above the
tree.

Verified: 6064 C# tests and 130 F# tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Rafael-SOWNet
Rafael-SOWNet force-pushed the fix/rewriterecording-asynclocal branch from 48160f2 to 5a5ef43 Compare August 10, 2026 02:48
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.

RewriteRecording's ambient scope is per-thread, so it does not survive an await

1 participant