Close the FX reclaimer's undo span at the state-load hook, and expose the drain's direction

A Ctrl-Z mid-span used to close the pre-undo block over post-undo state; it now
terminates at BeginLoadProjectState and stops the drain where it stands.
fxReclaimActivity reports Online/Offline/Idle; fxReclaimPending is unchanged.
This commit is contained in:
2026-08-07 13:45:58 -04:00
parent 86aa8dabba
commit 9b6f725747
5 changed files with 357 additions and 34 deletions
+23 -9
View File
@@ -84,12 +84,25 @@ decide membership or mode rules.
block swallows every edit the user makes afterwards, indefinitely, so every
terminating path routes through ONE idempotent close (`fxReclaimEndUndoSpan`): the
drain settling, aborting on a generation bump, or deferring on the transport; the
project retargeting or closing; the next apply superseding it; a project save;
extension unload. The span may stay open only across a wait it can BOUND — the 3s
debounce, which is the dead time between the switch and its first 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`).
project retargeting or closing; the next apply superseding it; a USER-DRIVEN STATE
LOAD; a project save; extension unload. The span may stay open only across a wait it
can BOUND — the 3s debounce, which is the dead time between the switch and its first
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`).
- **A Ctrl-Z terminates the span itself, at the hook, before REAPER restores
anything.** `fxReclaimAbandonForStateLoad` runs from `BeginLoadProjectState`
(`main.cpp`) — the only point at which the block still closes over the state it was
OPENED on. The span is open for at least `kParkHoldMs` after every real switch and a
switch is exactly what a user undoes, so letting the close happen where it used to —
as a side effect of the reapply the reloaded model triggers, a full timer tick later
— closed the pre-undo block over POST-undo state, minting a spurious point and
destroying the redo branch. The same call stops any drain on the stack where it
stands (`FxReclaimQueue::abandon`, refused at both host entry points), which is
stronger than an invalidation: an invalidation still finishes the track it is on,
because a step is atomic. Daniel's ruling, and the accepted cost: undoing a switch
mid-drain leaves a PARTIAL park — some FX offlined, some not — covered by the one
point the terminator closed.
- **A reapply that wrote nothing leaves no undo point** — and a reapply never holds
the span open, so a project open, a membership edit and `render_in_place`'s inner
reapply all keep the block shape they had. The load-tick reapply
@@ -148,7 +161,7 @@ applies the resulting lane state to live tracks.
- `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).
- `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). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative.
- `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 undo/redo 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
@@ -182,8 +195,9 @@ applies the resulting lane state to live tracks.
flip — still closes on `kViewUndoMask` alone and costs no FX serialization. What a
Ctrl-Z cannot restore is anything the reclaimer converges AFTER the span closed: a
transport deferral, and a drain no switch opened a span for, each mint their own
point. Whether that reads as correct in the DAW is `[verify — DAW]`,
`docs/VERIFICATION.md` §"Mode switching".
point — and, since the terminator above, whatever the drain had NOT yet written when
the undo arrived, because the span closes at that instant. Whether that reads as
correct in the DAW is `[verify — DAW]`, `docs/VERIFICATION.md` §"Mode switching".
- **The `view_state` write is NOT inside `applyMode`'s block — but on a REAL switch
it is now inside the SPAN.** `persistViewState` (`design_view_actions.cpp`) runs
after `applyMode` has returned, and a real switch returns with the span open, so