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
+179
View File
@@ -59,6 +59,14 @@ struct FakeHost {
int invalidateAfterWrite = -1;
int itemsSeenMidDrain = -1; // queue size as the re-entrant caller finds it
// A Ctrl-Z arriving through the same pump: what fxReclaimAbandonForStateLoad does,
// fired on the Nth successful write. The direction probe rides the same hook because
// the property it checks is the same one — what a caller re-entering mid-drain sees.
int abandonAfterWrite = -1;
int probeActivityAfterWrite = -1;
int spanClosesMidDrain = 0;
FxReclaimActivity activityMidDrain = FxReclaimActivity::Idle;
bool rolling = false; // the transport gate's input
// Stands in for the shell's one span instance: fx_reclaim.cpp opens it on the first
@@ -70,7 +78,10 @@ struct FakeHost {
double nowMs() const { return clock; }
bool transportRolling() const { return rolling; }
// Both host entry points refuse once abandoned, exactly as fx_reclaim.cpp's do: this
// one ends every REMAINING track, writeOffline below ends the one being written.
bool liveChain(const std::string& guid, std::vector<std::string>& out) {
if (reentrant && reentrant->abandoned()) return false;
auto it = chains.find(guid);
if (it == chains.end()) return false;
out.clear();
@@ -79,6 +90,7 @@ struct FakeHost {
}
bool writeOffline(const std::string& guid, int fxIndex, bool offline) {
if (reentrant && reentrant->abandoned()) return false;
auto it = chains.find(guid);
if (it == chains.end()) return false;
if (fxIndex < 0 || fxIndex >= static_cast<int>(it->second.size())) return false;
@@ -97,6 +109,12 @@ struct FakeHost {
reentrant->discard();
reentrant->invalidate();
}
if (reentrant && static_cast<int>(writeLog.size()) == probeActivityAfterWrite)
activityMidDrain = reentrant->activity(clock, rolling);
if (reentrant && static_cast<int>(writeLog.size()) == abandonAfterWrite) {
if (span && span->end()) ++spanClosesMidDrain; // closeSpan, then the stop
reentrant->abandon();
}
return true;
}
@@ -1197,6 +1215,160 @@ static void testADrainNoSwitchOpenedMintsOnePointOfItsOwn() {
CHECK(!span.open());
}
// -- 11. the state-load terminator ------------------------------------------------------
static void testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack() {
// The contrast with testAReentrantInvalidationReachesNothingTheDrainIsUsing above,
// which finishes its track's remaining three writes: an ordinary invalidation only
// says the model moved, while a state load says the PROJECT is being replaced under
// us and the block covering these writes has already closed. So a step is atomic only
// up to the abandon — the half-parked chain that leaves is the accepted cost.
ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}"}},
{"{T2}", {"{FX-C}"}}});
FakeHost host;
host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}"});
host.chains["{T2}"] = onlineChain({"{FX-C}"});
host.stepCostMs = 1.0; // cheap enough that only the abandon can stop the tick
UndoSpan span; // what a real switch left open
CHECK(span.begin(kProjA, "ReaSampler: activate Design view", 9));
host.span = &span;
FxReclaimQueue q = queueOf(vm, {"{T1}", "{T2}"});
q.run(host); // observes both parked at t=0
host.clock += kParkHoldMs;
host.reentrant = &q;
host.abandonAfterWrite = 1;
const ReclaimRunResult r = q.run(host);
CHECK(host.writeLog == std::vector<std::string>{"{T1}:0=off"});
CHECK(host.offlineAt("{T1}", 0));
CHECK(!host.offlineAt("{T1}", 1)); // the same track, mid-chain — the partial park
CHECK(!host.offlineAt("{T2}", 0)); // and every track after it, untouched
CHECK(host.spanClosesMidDrain == 1);
CHECK(!span.open());
CHECK(r.aborted);
CHECK(!r.settled);
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.
CHECK(q.stale());
CHECK(q.pending());
// ...and the stop belonged to that tick alone. The reloaded project's own drain writes.
host.abandonAfterWrite = -1;
q.adopt(derive(vm, {"{T1}", "{T2}"}));
CHECK(q.run(host).writes == 2); // {T1}'s remaining slot, and {T2}'s whole chain
CHECK(host.offlineAt("{T1}", 1));
CHECK(host.offlineAt("{T2}", 0));
}
// -- 12. the direction the drain is moving ----------------------------------------------
static void testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold() {
// The panel bug this surface exists for: `pending` is true from the instant of the
// switch, but for the whole kParkHoldMs the only thing eligible is the RETURNING
// chains' online work — an indicator driven off the queue's mere contents would name
// parking while the drain is doing the opposite.
ViewModeModel vm;
vm.membership().tag("{Z-back}", kArrangeModeId); // active; owed an online
vm.membership().tag("{A-park}", kDesignModeId); // parked; owed an offline
vm.storeSnapshot("{Z-back}", recordWithFx({{"{FX-1}", 0}}));
vm.storeSnapshot("{A-park}", recordWithFx({{"{FX-2}", 0}}));
FakeHost host;
host.chains["{Z-back}"] = offlineChain({"{FX-1}"});
host.chains["{A-park}"] = onlineChain({"{FX-2}"});
host.stepCostMs = 1.0;
FxReclaimQueue q = queueOf(vm, {"{Z-back}", "{A-park}"});
CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Online);
CHECK(q.run(host).writes == 1); // the online item only — the offline is still held
CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle);
host.clock += kParkHoldMs;
CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Offline);
CHECK(q.run(host).settled);
CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle);
}
static void testAnOfflineOnlyQueueReadsIdleUntilItsHoldMatures() {
ViewModeModel vm = parkedDesignLeaf({"{FX-A}"});
FakeHost host;
host.chains["{T}"] = onlineChain({"{FX-A}"});
FxReclaimQueue q = queueOf(vm, {"{T}"});
// Idle, not Offline: nothing may move, and the wait is the reason the span is allowed
// to stay open across it. Reporting Offline here is what would light the indicator for
// 3s before a single plugin moves.
CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle);
CHECK(!q.run(host).settled); // the hold's origin is set by this tick's observe
CHECK(host.writeLog.empty());
CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle);
host.clock += kParkHoldMs;
CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Offline);
CHECK(q.run(host).writes == 1);
}
static void testARollingTransportHidesOfflineWorkFromTheDirectionButNeverOnline() {
ViewModeModel vm;
vm.membership().tag("{Z-back}", kArrangeModeId);
vm.membership().tag("{A-park}", kDesignModeId);
vm.storeSnapshot("{Z-back}", recordWithFx({{"{FX-1}", 0}}));
vm.storeSnapshot("{A-park}", recordWithFx({{"{FX-2}", 0}}));
FakeHost host;
host.chains["{Z-back}"] = offlineChain({"{FX-1}"});
host.chains["{A-park}"] = onlineChain({"{FX-2}"});
host.stepCostMs = 1.0;
host.rolling = true;
FxReclaimQueue q = queueOf(vm, {"{Z-back}", "{A-park}"});
// Online is not transport-gated, so it both drains and reads through a rolling
// transport — the second half of the panel bug: this work had no indicator at all.
CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Online);
CHECK(q.run(host).writes == 1);
host.clock += kParkHoldMs;
// The offline half is eligible now and still may not move, so it reads Idle rather
// than naming a direction nothing is travelling in.
CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle);
CHECK(q.run(host).transportDeferred);
CHECK(host.writeLog.size() == 1);
host.rolling = false;
CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Offline);
CHECK(q.run(host).writes == 1);
}
static void testTheDirectionHoldsThroughTheDrainsOwnMessagePump() {
// A write pumps, and a repaint landing in there polls this. The queue object holds
// NOTHING for the drain's whole duration (itemsSeenMidDrain == 0, asserted above), so
// without the latch the indicator would read Idle at exactly the moment the plugin
// loads the user is waiting through are happening.
ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}"}}});
FakeHost host;
host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}"});
host.stepCostMs = 1.0;
FxReclaimQueue q = queueOf(vm, {"{T1}"});
q.run(host);
host.clock += kParkHoldMs;
host.reentrant = &q;
host.probeActivityAfterWrite = 1;
CHECK(q.run(host).settled);
CHECK(host.activityMidDrain == FxReclaimActivity::Offline);
CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); // latch released
}
int main() {
testFastFlipMovesZeroPluginsButAHeldParkStillReclaims();
@@ -1240,6 +1412,13 @@ int main() {
testOneSwitchAndItsMultiTickParkingAreOneSpan();
testADrainNoSwitchOpenedMintsOnePointOfItsOwn();
testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack();
testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold();
testAnOfflineOnlyQueueReadsIdleUntilItsHoldMatures();
testARollingTransportHidesOfflineWorkFromTheDirectionButNeverOnline();
testTheDirectionHoldsThroughTheDrainsOwnMessagePump();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}