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
+68 -3
View File
@@ -222,6 +222,18 @@ FxReclaimQueue queueOf(const ViewModeModel& vm, const std::set<std::string>& liv
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
// -- 1. debounce ---------------------------------------------------------------
@@ -1254,10 +1266,11 @@ static void testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack(
CHECK(spanEndsAfter(r)); // the tick's own close still fires...
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
// half-parked chain for the rest of the session.
// The work is owed again, and the loop is ASLEEP until something re-reads the model —
// 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.pending());
CHECK(!q.pending());
// ...and the stop belonged to that tick alone. The reloaded project's own drain writes.
host.abandonAfterWrite = -1;
@@ -1267,6 +1280,57 @@ static void testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack(
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 ----------------------------------------------
static void testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold() {
@@ -1413,6 +1477,7 @@ int main() {
testADrainNoSwitchOpenedMintsOnePointOfItsOwn();
testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack();
testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel();
testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold();
testAnOfflineOnlyQueueReadsIdleUntilItsHoldMatures();