One undo-block opener for the whole tree: fourteen sites route through it, and the reclaim span can no longer swallow a user's act

The drain refuses a tick inside a foreign block; applyMode wakes the reclaimer
before it opens the span.
This commit is contained in:
2026-08-07 16:38:11 -04:00
parent b2f6b3f1d0
commit b7b93a7ac3
25 changed files with 561 additions and 313 deletions
+46 -26
View File
@@ -122,9 +122,16 @@ decide membership or mode rules.
FX write, and the only reason the span exists. A TRANSPORT deferral is not bounded
(it lasts as long as the playback pass), so it ends the span and the parking that
follows mints its own point (`spanEndsAfter`).
- **NOTHING MAY OPEN AN UNDO BLOCK WHILE THE SPAN IS OPEN — every opener ends it
first, as its first act.** REAPER's blocks are read as a DEPTH COUNTER, not a stack
of identities (`[verify — DAW]`, and the same undocumented nested-block behaviour the
Stated as ONE property over the pure state, which is how it is tested
(`tests/test_fx_reclaim.cpp`, at every terminator rather than incidentally in one
scenario): **`!pending()` implies `!span.open()`.** The timer gates every tick on
`pending()`, so a span left open with the wakeup down has nothing that could ever
close it. That is why `applyMode` raises the wakeup BEFORE it opens the span rather
than on its way out — a throw in between would otherwise leak the block with the
reclaimer asleep — and why the state-load terminator closes BEFORE it abandons.
- **NOTHING MAY OPEN AN UNDO BLOCK WHILE THE SPAN IS OPEN — and no site decides that
for itself any more.** REAPER's blocks are read as a DEPTH COUNTER, not a stack of
identities (`[verify — DAW]`, and the same undocumented nested-block behaviour the
Gotchas below name): `Undo_EndBlock2` at depth 2->1 mints nothing and drops the label
and mask it was handed, and the close that does reach depth 0 mints under whatever
label IT carries. So a block opened over the span leaves each close paired with the
@@ -134,18 +141,31 @@ decide membership or mode rules.
reading: ending the span first yields the switch's point and the opener's as two
correctly-labelled points under the counter reading AND under an identity-pairing one,
which is why it is stated as an unconditional discipline rather than a workaround for
a behaviour nobody has observed. Ending it first mints the switch's point whole, at
depth 0, and leaves the opener's block the only one in play. The
openers, all of them: `design_view_actions`' `ActionUndoBlock` (whose constructor
ends the span, so all four membership actions inherit it), `view_lanes`'
`mintManagedLanes`, and `shell/capture`'s `render_in_place`. The mirror half is
`applyMode`'s: a REAPPLY never closes a span, precisely because it is reachable from
inside one of those blocks — only a real switch supersedes, and a real switch is
always fired at depth 0. RESIDUAL, out of this rule's reach: an extension undo block
ELSEWHERE (a capture, a bank verb, an ingest) that pumps the message loop can re-enter
the timer, and a drain tick that settles in there closes the span at depth 2->1 with
the same result. Closing that needs every extension-owned `Undo_BeginBlock2` to route
through one opener that ends the span — not attempted here.
a behaviour nobody has observed. **The mechanism is `shell/undo`'s `UndoBlock`, which
every extension-owned block in the tree now opens through — its constructor ends the
span when, and only when, it is the OUTERMOST block, so a nested opener cannot close
at depth 2->1 and hijack its enclosing block.** That directory's `CLAUDE.md` owns the
mechanism and the compile-time enforcement behind it; here it closes three things at
once. First, the openers no longer have to be enumerated by hand — the former list
(`ActionUndoBlock`, `mintManagedLanes`, `render_in_place`) was never the whole set and
omitted ten user-fired blocks in `shell/capture`, `shell/bank_ops` and
`shell/actions`, each of which absorbed the user's own act into the mode switch.
Second, `mintManagedLanes` gets the same nesting criterion as everything else: it is
called from inside `doMoveItems`' and `render_in_place`' blocks, where it used to end
the span unconditionally. Third, the RESIDUAL in the other direction is closed too —
a foreign block's message pump can re-enter the timer, and a drain tick in there would
open its span at depth 2; `fxReclaimTick` now refuses outright while `undoBlockOpen()`
and consumes nothing, so it simply drains on the first tick after the block closes.
The mirror half is still `applyMode`'s: a REAPPLY never closes a span, precisely
because it is reachable from inside one of those blocks — only a real switch
supersedes, and a real switch is always fired at depth 0.
ACCEPTED, and not fixed: `mintManagedLanes` opens its block once its plan is
non-empty, but a non-empty plan whose writes are all already satisfied still mints
nothing — so a background detect tick can end a switch's span having competed with it
for nothing. Telling the two apart means running the plan, which is the write. The
cost is bounded at ONE extra undo point (the switch mints early; its remaining parking
mints again under `kReclaimUndoLabel`), the same degradation a transport deferral
already carries, where the alternative destroys the switch's point outright.
- **EVERY project-state load terminates the span itself, at the hook, before REAPER
restores anything — `isUndo` does not narrow it.**
`fxReclaimAbandonForStateLoad` runs UNBRANCHED from `BeginLoadProjectState`
@@ -234,13 +254,13 @@ applies the resulting lane state to live tracks.
## Modules
- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and on a REAL SWITCH ends any prior span and opens the ONE undo span all of that rides (`fx_reclaim` is what closes it; a reapply opens none and closes none). Reports what it wrote (`ModeApplyResult`) so a caller holding its own block can discard an empty point. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`.
- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and on a REAL SWITCH ends any prior span, raises the reclaimer's wakeup, and opens the ONE undo span all of that rides (`fx_reclaim` is what closes it; a reapply opens none and closes none). Reports what it wrote (`ModeApplyResult`) so a caller holding its own block can discard an empty point. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`.
- `view_park` — the park surface, FLAG DOMAIN ONLY. `parkTrack` captures the pre-park snapshot (flags AND the FX chain's identity-keyed offline state) when the model holds none, drives the four flags, and EXPOSEs a track that reads park-shaped with no record behind it rather than adopting it; `restoreTrack` writes the flags back from the snapshot and spends its flag half (`releaseSnapshotFlags`). Also `trackFlagParm` (the one `Flag` → REAPER-parameter mapping), `writeIfChanged` (the read-before-write every driven parameter goes through), `fxGuidString` (the one FX-identity read snapshot/reclaim both address FX through), `kViewUndoMask`, and `reportExposedTracks`/`describeExposedTracks` (named tracks, once per changed set per project).
- `view_lanes` — managed fixed lanes: `mintManagedLanes` splits multi-mode content onto per-mode lanes, `applyLaneOps` drives `C_LANEPLAYS` for the active mode, `reconcileManagedLanes` recovers ownership from durable `P_LANENAME` on project open. Manual lanes are never minted, driven, renamed or reassigned.
- `view_reconcile` — the repair pass for a project state REAPER reloaded under us: `noteAppliedState` records what an apply left driven; `reconcileAppliedFlags` diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for `UNDO_STATE_TRACKCFG` not carrying `B_SHOWINTCP`/`B_SHOWINMIXER` (DAW-confirmed). It opens no undo block: it runs in the wake of a Ctrl-Z, and its writes are flags only.
- `view_tracks` — the scaffolding every write pass sets up: `TrackHandles`/`TrackByGuid`, `readFolderEntries`/`indexHandles`, and `UiRefreshHold` (the `PreventUIRefresh` RAII pair every write phase runs under).
- `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`.
- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). `UndoSpan` owns the ONE undo block the whole drain writes under — opened by `applyMode` on a real switch, else lazily by the first `writeOffline`, and closed through the single idempotent `fxReclaimEndUndoSpan` that every terminating path routes through (`spanEndsAfter` decides which ticks terminate; `fxReclaimAbandonForStateLoad` is the state-load terminator, which also stops the drain). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. `fxReclaimActivity` reports WHICH DIRECTION the drain is moving for an indicator to paint — Idle whenever nothing may move, so a debounced or transport-held offline item never names a direction nothing is travelling in; undirected `fxReclaimPending` stays the timer's one idle test and must not drive a UI (it is true throughout the debounce, when only the OPPOSITE direction is eligible).
- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). `UndoSpan` owns the ONE undo block the whole drain writes under — opened by `applyMode` on a real switch, else lazily by the first `writeOffline`, never while an extension undo block is open (`fxReclaimTick` refuses the tick outright, consuming nothing), and closed through the single idempotent `fxReclaimEndUndoSpan` that every terminating path routes through (`spanEndsAfter` decides which ticks terminate; `fxReclaimAbandonForStateLoad` is the state-load terminator, which also stops the drain). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. `fxReclaimActivity` reports WHICH DIRECTION the drain is moving for an indicator to paint — Idle whenever nothing may move, so a debounced or transport-held offline item never names a direction nothing is travelling in; undirected `fxReclaimPending` stays the timer's one idle test and must not drive a UI (it is true throughout the debounce, when only the OPPOSITE direction is eligible).
## Gotchas
@@ -338,15 +358,15 @@ applies the resulting lane state to live tracks.
into sync.
- **"Nothing nests on the reapply paths" was FALSE, and cost the switch its undo
point.** `render_in_place` and the membership actions each call `applyMode` inside
their own `Undo_BeginBlock2` pair; the reapply opens no block of its own, but a prior
REAL SWITCH's span can still be open — for `kParkHoldMs` plus the whole drain — and
it is a block like any other. Two things nest there, and both are now handled by the
"nothing may open a block while the span is open" invariant: the caller's block over
the span (each caller ends the span first), and the reapply's former unconditional
supersede-close inside the caller's block (only a real switch closes now). What still
nests legitimately: `doMoveItems` and `render_in_place` over `mintManagedLanes`, both
properly paired inside a single outer block, so the SDK's undocumented nested-block
coalescing is load-bearing only for that shape.
their own block; the reapply opens none of its own, but a prior REAL SWITCH's span can
still be open — for `kParkHoldMs` plus the whole drain — and it is a block like any
other. Two things nest there, and both are now handled by the "nothing may open a
block while the span is open" invariant: the caller's block over the span (`UndoBlock`
ends it at the outermost open), and the reapply's former unconditional supersede-close
inside the caller's block (only a real switch closes now). What still nests
legitimately: `doMoveItems` and `render_in_place` over `mintManagedLanes`, all three
`UndoBlock`s, so the SDK's undocumented nested-block coalescing is load-bearing only
for that shape.
- `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays
attached to its plugin across a chain mutation while parked. That is
`[verify — DAW]` (see `fxGuidString` in `view_park.cpp`) and SWS issue #802 is a