Put the FX reclaimer to sleep on a project-state load instead of waking it

abandon() clears the wakeup where invalidate() raises it, so File > Revert
cannot converge FX or spend park records against a model nothing re-read.
This commit is contained in:
2026-08-07 14:23:56 -04:00
parent 39b995e824
commit 67d9ec3da9
4 changed files with 106 additions and 9 deletions
+16
View File
@@ -113,6 +113,22 @@ decide membership or mode rules.
`abandon`. Daniel's ruling, and the accepted cost: undoing a switch mid-drain leaves `abandon`. 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 a PARTIAL park — some FX offlined, some not — covered by the one point the
terminator closed. terminator closed.
- **A state load puts the reclaimer to SLEEP; only a re-read of the model wakes it.**
`abandon` clears the wakeup where `invalidate` raises it, because the two say
different things: an invalidation says the MODEL moved and owes a re-derivation, a
load says the PROJECT moved under a model nobody has re-read yet. `applyMode` is
`invalidateFxReclaim`'s one caller and `fxReclaimPending` gates the tick that is the
one path to any FX write or park-record spend, so every load that re-reads the model
wakes the loop on the same tick, through the reapply the reload drives — and a load
that re-reads nothing wakes nothing. `File > Revert` is exactly that load: same
`ReaProject`, same GUID, same path, so the identity poll answers `NoOp` and the model
is never re-read. Waking there was the only thing that ran, and it converged the FX
chains toward the pre-revert model and spent the park records the reverted project
still needed. The accepted cost of the sleep: an outstanding reclaim in a project
whose interrupting load was some OTHER project's waits for that project's next apply
rather than resuming on the next tick. (That a revert leaves the view model stale at
all is the identity poll's defect, not the reclaimer's — this invariant only keeps the
reclaimer from acting on it.)
- **A reapply that wrote nothing leaves no undo point** — and a reapply never holds - **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 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 reapply all keep the block shape they had. The load-tick reapply
+3 -3
View File
@@ -281,9 +281,9 @@ void fxReclaimLabelUndoSpan(const char* label, int mask) {
void fxReclaimEndUndoSpan() { closeSpan(); } void fxReclaimEndUndoSpan() { closeSpan(); }
void fxReclaimAbandonForStateLoad() { void fxReclaimAbandonForStateLoad() {
// Close FIRST and unconditionally. The abandon below only stops future writes; the // Close FIRST and unconditionally. The abandon below stops the drain and puts the loop
// block is what a Ctrl-Z would otherwise close over the wrong state, and it is open // to sleep; the block is what a Ctrl-Z would otherwise close over the wrong state, and
// whether or not a drain is on the stack at all. // it is open whether or not a drain is on the stack at all.
closeSpan(); closeSpan();
g_queue.abandon(); g_queue.abandon();
} }
+19 -3
View File
@@ -369,9 +369,25 @@ public:
// closed. A load that keeps the SAME ReaProject (a revert) is what makes the stop // closed. A load that keeps the SAME ReaProject (a revert) is what makes the stop
// load-bearing rather than tidy: the drain's pin and cached track handle both still // load-bearing rather than tidy: the drain's pin and cached track handle both still
// validate afterwards, so nothing else would keep it out of the reloaded project. // validate afterwards, so nothing else would keep it out of the reloaded project.
//
// The wakeup goes DOWN, where an invalidation raises it — do not reintroduce the raise.
// An invalidation says the MODEL moved and owes a re-derivation; a load says the
// PROJECT moved under a model nobody has re-read yet, which is the opposite of a reason
// to converge. Every load that re-reads the model raises the wakeup itself, on that
// same tick, through the reapply the reload drives (applyMode is invalidateFxReclaim's
// one caller); File > Revert re-reads nothing, so waking here was the only thing that
// ever ran, and it wrote FX and spent park records against the pre-revert model.
//
// Safe to clear state under a drain, unlike invalidate(): the queue's own storage is
// empty for the drain's whole duration (run() moved it out), and run()'s abort path
// returns before it would read derived_ or commit anything.
void abandon() { void abandon() {
invalidate(); ++generation_;
abandoned_ = true; abandoned_ = true;
discard();
pending_ = false;
// The pin STAYS — it is what the next tick's retarget tests the debounce clock
// against, and that clock outlives this exactly as it outlives an invalidation.
} }
// 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
@@ -666,8 +682,8 @@ 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 and stops any drain on the stack // File > Revert, a new project. Ends the span, stops any drain on the stack, and leaves the
// (FxReclaimQueue::abandon). // loop ASLEEP until something that re-read the model wakes it (FxReclaimQueue::abandon).
// //
// 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
+68 -3
View File
@@ -222,6 +222,18 @@ FxReclaimQueue queueOf(const ViewModeModel& vm, const std::set<std::string>& liv
return q; return q;
} }
// One whole timer tick as the shell makes it: main.cpp's ONE pending() test, then
// fxReclaimTick's pin, its re-derivation from the session's model whenever the queue is
// stale, and the drain. The re-derivation is what makes the wakeup the load-bearing bit —
// it reads whatever model is live right now, whether or not anything re-read it since.
void timerTick(FxReclaimQueue& q, const ViewModeModel& vm, const std::set<std::string>& live,
FakeHost& host) {
if (!q.pending()) return;
if (!q.beginTick(host.project)) return;
if (q.stale()) q.adopt(derive(vm, live));
q.run(host);
}
} // namespace } // namespace
// -- 1. debounce --------------------------------------------------------------- // -- 1. debounce ---------------------------------------------------------------
@@ -1254,10 +1266,11 @@ static void testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack(
CHECK(spanEndsAfter(r)); // the tick's own close still fires... CHECK(spanEndsAfter(r)); // the tick's own close still fires...
CHECK(!span.end()); // ...and finds nothing: the terminator got there first CHECK(!span.end()); // ...and finds nothing: the terminator got there first
// The wakeup stands and the work is owed again: going idle here would strand the // The work is owed again, and the loop is ASLEEP until something re-reads the model —
// half-parked chain for the rest of the session. // the reapply a reload drives is what raises the wakeup, and a load that drives none
// must raise nothing (testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel).
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.
host.abandonAfterWrite = -1; host.abandonAfterWrite = -1;
@@ -1267,6 +1280,57 @@ static void testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack(
CHECK(host.offlineAt("{T2}", 0)); CHECK(host.offlineAt("{T2}", 0));
} }
static void testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel() {
// 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
// does either. The terminator is the only thing that runs, so whether it raises the
// wakeup decides the whole outcome. Online work is the sharp case — undebounced, so ONE
// woken tick both writes FX and spends the record's FX half, against the very model the
// user reverted away from. The contrast with the mid-drain terminator test above: there
// the load arrives through a write's own message pump, here between two ticks entirely.
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"});
vm.setActiveMode(kDesignModeId);
vm.releaseSnapshotFlags("{T}");
FakeHost host;
host.model = &vm;
host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"});
ReleaseFlush flush;
host.flush = &flush;
FxReclaimQueue q;
CHECK(q.beginTick(kProjA)); // where an earlier tick left it: pinned, nothing owed
q.invalidate(); // applyMode, leaving the switch that brought {T} back
CHECK(q.pending());
q.abandon();
CHECK(!q.pending());
CHECK(q.stale());
CHECK(q.items().empty());
CHECK(q.project() == kProjA); // the pin stays: it is what guards the debounce clock
// Timer ticks keep arriving. The queue is empty AND stale, so it is the wakeup alone
// that keeps the drain out — timerTick re-derives from the stale model exactly as the
// shell would, which is what a raise here would have handed the drain.
for (int i = 0; i < 3; ++i) timerTick(q, vm, {"{T}"}, host);
CHECK(host.writeLog.empty());
CHECK(host.released.empty());
CHECK(!flush.pendingFor(kProjA));
// ...and the chain is untouched — still parked offline, as the revert left it.
CHECK(host.offlineAt("{T}", 0) && host.offlineAt("{T}", 1));
// The contrast, so the assertions above are about the wakeup and not about this queue
// being unable to converge: a load that DOES re-read the model reaches the reclaimer
// through its reapply's invalidate, and the very same tick over the very same model
// both writes and spends.
q.invalidate();
timerTick(q, vm, {"{T}"}, host);
CHECK(host.writeLog.size() == 2);
CHECK(!host.offlineAt("{T}", 0) && !host.offlineAt("{T}", 1));
CHECK(host.released.count("{T}") == 1);
CHECK(flush.pendingFor(kProjA));
}
// -- 12. the direction the drain is moving ---------------------------------------------- // -- 12. the direction the drain is moving ----------------------------------------------
static void testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold() { static void testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold() {
@@ -1413,6 +1477,7 @@ int main() {
testADrainNoSwitchOpenedMintsOnePointOfItsOwn(); testADrainNoSwitchOpenedMintsOnePointOfItsOwn();
testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack(); testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack();
testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel();
testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold(); testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold();
testAnOfflineOnlyQueueReadsIdleUntilItsHoldMatures(); testAnOfflineOnlyQueueReadsIdleUntilItsHoldMatures();