Keep the extension's tick out of REAPER's own project load, and re-earn the park holds across one load

This commit is contained in:
2026-08-07 18:03:58 -04:00
parent 3d95eb715c
commit 20fef2a3bf
4 changed files with 124 additions and 9 deletions
+11
View File
@@ -183,6 +183,17 @@ static void OnTimer()
// the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live. // the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live.
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
// A project load or save is IN FLIGHT, so this tick is running inside its own message
// pump — a restore brings plugins online and a plugin load pumps. Everything below
// would then act on a half-restored project: poll() reads the pre-load <EXTSTATE> that
// OnBeginLoadProjectState defers past precisely because it is wrong, and the reclaim
// tick writes FX into a project REAPER is still rewriting. The extension's other
// re-entrancy guards cover OUR pumps; this one covers REAPER's. Consumes nothing, so
// the first tick after the load runs as if this one had never been due. Self-lowering
// (reaper_plugin_functions.h:1584-1587), and placed after the realtime drive so that
// fast path keeps its single-pointer-test shape. See src/shell/view/CLAUDE.md.
if (GetCurrentProjectInLoadSave()) return;
// poll()'s undo/redo-reload and project-switch Load branches call // poll()'s undo/redo-reload and project-switch Load branches call
// loadFromProject, replacing g_session.view() wholesale — deferred here while // loadFromProject, replacing g_session.view() wholesale — deferred here while
// an applyMode is on the stack (reachable if a pumped message loop re-enters // an applyMode is on the stack (reachable if a pumped message loop re-enters
+30 -4
View File
@@ -108,9 +108,12 @@ decide membership or mode rules.
ONE undo block: `applyMode` opens it on a REAL SWITCH ONLY (`fx_reclaim`'s ONE undo block: `applyMode` opens it on a REAL SWITCH ONLY (`fx_reclaim`'s
`UndoSpan`) and RETURNS WITH IT OPEN, for the reclaimer to close once the drain has `UndoSpan`) and RETURNS WITH IT OPEN, for the reclaimer to close once the drain has
converged. This does not move an FX write back onto the switch — the writes still converged. This does not move an FX write back onto the switch — the writes still
run on the timer, off the 1897ms path. Whether a block held from an ACTION into a run on the timer, off the 1897ms path. **DAW-CONFIRMED, no longer inferred:** a block
later timer callback holds is `[verify — DAW]`; if it does not, the switch and its opened in the `applyMode` ACTION and closed several timer callbacks later by the drain
parking land as two points, which is the intended degradation. holds — switch, then an undisturbed parking drained to completion, then one Ctrl-Z gave
exactly ONE undo entry and returned to the previous mode. (The two-point degradation
this once hedged against never materialised; the degradations that DO occur are the
transport deferral and `mintManagedLanes`' early close, both named below.)
- **No path may return past a terminating condition with the span open.** A leaked - **No path may return past a terminating condition with the span open.** A leaked
block swallows every edit the user makes afterwards, indefinitely, so every block swallows every edit the user makes afterwards, indefinitely, so every
terminating path routes through ONE idempotent close (`fxReclaimEndUndoSpan`): the terminating path routes through ONE idempotent close (`fxReclaimEndUndoSpan`): the
@@ -225,7 +228,30 @@ decide membership or mode rules.
OFFLINE work once a track has read continuously parked for `kParkHoldMs` OFFLINE work once a track has read continuously parked for `kParkHoldMs`
(3000ms). A flip inside that window flips the flags twice but the debounce (3000ms). A flip inside that window flips the flags twice but the debounce
never matures, so the reclaimer finds every chain already where the record never matures, so the reclaimer finds every chain already where the record
wants it and writes nothing. wants it and writes nothing. **Undoing a switch mid-park is that same flip**, so a
state load DROPS the holds where an invalidation keeps them (`FxReclaimQueue::abandon`,
and again at the end of `run` for an abandon that arrived through a write's own pump,
which found the clock moved into the drain's local). Keeping them fired the post-load
re-park on the FIRST tick after the load, with zero debounce, in the wake of REAPER's
own restore; re-earning them is what makes the undo cost nothing and keeps the writes
clear of the load.
- **NOTHING THE EXTENSION DOES PER TICK RUNS WHILE REAPER IS IN A PROJECT LOAD OR SAVE.**
`BeginLoadProjectState` fires before REAPER restores anything, and the restore itself
brings plugins online — a plugin load pumps the message loop, which is how a timer tick
lands INSIDE the load. `main.cpp`'s `OnTimer` therefore returns immediately while
`GetCurrentProjectInLoadSave()` names a project
(`reaper_plugin_functions.h:1584-1587`), self-lowering, with no latch to leak. Without
it, that tick's `poll()` reads the half-restored `<EXTSTATE>` — the very pre-load value
the hook defers past because it is wrong — the reapply plans against it, and the reclaim
tick writes FX into a project REAPER is still rewriting; each such round is its own
settle-and-close, so a re-convergence that owes ONE point costs one per round. That last
step is reachable by inspection, not measured: the DAW report it answers is one undo
entry per FX after a Ctrl-Z mid-drain, and whether this is the whole of that cause is
`[verify — DAW]`. The extension's other re-entrancy guards (`modeApplyInProgress`,
`fxReclaimDrainInProgress`) cover OUR pumps only; this is the one that covers REAPER's.
It consumes nothing — reload request, load signal, wakeup, derived queue and pin all
stand. The realtime-capture drive deliberately sits AHEAD of it, keeping its
single-pointer-test idle path and its behaviour across a load unchanged.
- **Show-both semantics:** a per-track "pin visible across modes" flag re-enables - **Show-both semantics:** a per-track "pin visible across modes" flag re-enables
processing whenever shown. A show-both leaf appears in every mode's visible set processing whenever shown. A show-both leaf appears in every mode's visible set
and is never parked — its driven flags stay at snapshot/restored values, FX and is never parked — its driven flags stay at snapshot/restored values, FX
+20 -4
View File
@@ -386,8 +386,18 @@ public:
abandoned_ = true; abandoned_ = true;
discard(); discard();
pending_ = false; pending_ = false;
// The pin STAYS — it is what the next tick's retarget tests the debounce clock // The HOLDS GO, where an invalidation keeps them — the one place the two differ on
// against, and that clock outlives this exactly as it outlives an invalidation. // the clock, and for the same reason they differ on the wakeup. A hold records "this
// track has read continuously parked since T", an observation about the project this
// load is replacing; after the load the chains may sit anywhere (an undo of a park
// restores them online) and no such continuity is in evidence. Keeping it fired the
// post-load re-park on the FIRST tick after the load, with zero debounce, right in
// the wake of REAPER's restore. Undoing a switch mid-park is precisely the A->B->A
// flip kParkHoldMs exists to make free: re-earning the hold is what makes it free
// again, and what keeps the re-convergence's writes clear of the load.
clock_.forget();
// The pin STAYS — beginTick compares it to decide a retarget, and losing it there
// would read as "no project" rather than "same project, nothing derived yet".
} }
// Read by the shell's host on every step; false again from the next run(), because the // Read by the shell's host on every step; false again from the next run(), because the
@@ -477,6 +487,11 @@ public:
// The hold is keyed on the track and outlives any re-derivation, so it comes // The hold is keyed on the track and outlives any re-derivation, so it comes
// back whether or not the drain's own work survives. // back whether or not the drain's own work survives.
clock_.swap(clock); clock_.swap(clock);
// ...but not across a state load. An abandon reaching us through a write's own
// message pump found the clock already moved out into the drain's local, so its
// forget cleared an empty member; redo it now the holds are back. `abandoned_` is
// cleared at this call's entry, so it can only mean an abandon landed inside it.
if (abandoned_) clock_.forget();
// Assembled field by field rather than positionally: the aggregate is read by // Assembled field by field rather than positionally: the aggregate is read by
// the undo span's close decision now, and a silently mis-slotted bool there // the undo span's close decision now, and a silently mis-slotted bool there
@@ -686,8 +701,9 @@ void fxReclaimLabelUndoSpan(const char* label, int mask);
void fxReclaimEndUndoSpan(); void fxReclaimEndUndoSpan();
// EVERY project-state load, undo-driven or not — Ctrl-Z, Ctrl-Shift-Z, a project open, a // EVERY project-state load, undo-driven or not — Ctrl-Z, Ctrl-Shift-Z, a project open, a
// File > Revert, a new project. Ends the span, stops any drain on the stack, and leaves the // File > Revert, a new project. Ends the span, stops any drain on the stack, drops the
// loop ASLEEP until something that re-read the model wakes it (FxReclaimQueue::abandon). // debounce holds, and leaves the loop ASLEEP until something that re-read the model wakes
// it — FxReclaimQueue::abandon owns all four and the reasoning behind each.
// //
// Called from the projectconfig BeginLoadProjectState hook, which fires BEFORE REAPER // Called from the projectconfig BeginLoadProjectState hook, which fires BEFORE REAPER
// restores anything: that is the ONLY point at which the block can still close over the // restores anything: that is the ONLY point at which the block can still close over the
+63 -1
View File
@@ -1272,14 +1272,75 @@ static void testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack(
CHECK(q.stale()); CHECK(q.stale());
CHECK(!q.pending()); CHECK(!q.pending());
// ...and the stop belonged to that tick alone. The reloaded project's own drain writes. // ...and the stop belonged to that tick alone. The reloaded project's own drain writes —
// but not before its holds are earned again: the abandon dropped them (it reached the
// clock through the drain's own local, see FxReclaimQueue::run), so the re-park starts
// its 3s from the first post-load tick rather than inheriting the pre-load observation.
host.abandonAfterWrite = -1; host.abandonAfterWrite = -1;
q.adopt(derive(vm, {"{T1}", "{T2}"})); q.adopt(derive(vm, {"{T1}", "{T2}"}));
CHECK(q.run(host).writes == 0); // first post-load tick: the holds start here
CHECK(!host.offlineAt("{T1}", 1));
host.clock += kParkHoldMs;
CHECK(q.run(host).writes == 2); // {T1}'s remaining slot, and {T2}'s whole chain CHECK(q.run(host).writes == 2); // {T1}'s remaining slot, and {T2}'s whole chain
CHECK(host.offlineAt("{T1}", 1)); CHECK(host.offlineAt("{T1}", 1));
CHECK(host.offlineAt("{T2}", 0)); CHECK(host.offlineAt("{T2}", 0));
} }
static void testTheWholePostLoadReconvergenceIsOneSpanHoweverFarTheParkingGot() {
// The acceptance property behind a Ctrl-Z landing anywhere in the drain: whatever
// fraction of the parking had completed, everything the reloaded project owes rides
// ONE span and costs ONE point. Two things carry it — the abandon dropping the holds,
// so nothing writes on the tick that re-derives (the load's own wake), and the lazy
// open being the drain's, so the many ticks that follow share one block.
ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}"}},
{"{T2}", {"{FX-C}"}},
{"{T3}", {"{FX-D}"}}});
FakeHost host;
host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}"});
host.chains["{T2}"] = onlineChain({"{FX-C}"});
host.chains["{T3}"] = onlineChain({"{FX-D}"});
host.stepCostMs = 40.0; // over the offline budget: one track per tick, many ticks
UndoSpan span; // the switch's, still open when the undo arrives
CHECK(span.begin(kProjA, "ReaSampler: activate Design view", 9));
host.span = &span;
FxReclaimQueue q = queueOf(vm, {"{T1}", "{T2}", "{T3}"});
q.run(host);
host.clock += kParkHoldMs;
host.reentrant = &q;
host.abandonAfterWrite = 1; // Ctrl-Z one write in — the parking is part-done
q.run(host);
CHECK(!span.open()); // the terminator closed the switch's point over pre-load state
host.reentrant = nullptr;
host.abandonAfterWrite = -1;
// The reloaded model's reapply raises the wakeup and the queue re-derives.
q.invalidate();
q.adopt(derive(vm, {"{T1}", "{T2}", "{T3}"}));
int closes = 0;
auto tick = [&]() {
const ReclaimRunResult r = q.run(host);
if (spanEndsAfter(r) && span.end()) ++closes;
return r;
};
CHECK(!tick().settled); // the holds start here, so this tick writes nothing at all
CHECK(host.spanOpens == 0);
CHECK(!span.open());
CHECK(closes == 0);
host.clock += kParkHoldMs;
int ticks = 0;
while (!tick().settled && ++ticks < 20) {}
CHECK(ticks >= 2); // it really did take several ticks to converge...
CHECK(host.spanOpens == 1); // ...under ONE span, opened by the drain's first write
CHECK(closes == 1); // ...closed exactly once, on the tick that settled
CHECK(!span.open());
CHECK(host.offlineAt("{T1}", 1) && host.offlineAt("{T2}", 0) && host.offlineAt("{T3}", 0));
}
static void testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel() { static void testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel() {
// File > Revert is the load that re-reads NOTHING: it keeps the same ReaProject, so the // File > Revert is the load that re-reads NOTHING: it keeps the same ReaProject, so the
// session's identity poll answers NoOp, no reload follows and therefore no reapply // session's identity poll answers NoOp, no reload follows and therefore no reapply
@@ -1576,6 +1637,7 @@ int main() {
testADrainNoSwitchOpenedMintsOnePointOfItsOwn(); testADrainNoSwitchOpenedMintsOnePointOfItsOwn();
testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack(); testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack();
testTheWholePostLoadReconvergenceIsOneSpanHoweverFarTheParkingGot();
testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel(); testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel();
testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold(); testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold();