Fix the reclaimer stranding a tab-switched-into project: lift the pin and wakeup into FxReclaimQueue, retarget instead of going idle

Also validate the cached track in its project, and stop a drop-free tick resetting the report memo.
This commit is contained in:
2026-08-06 22:16:41 -04:00
parent 35fdfca805
commit 224dcf84df
4 changed files with 234 additions and 56 deletions
+145 -1
View File
@@ -4,10 +4,12 @@
// yield, the queue being a cache of intent that a model change discards, the user's own
// offline plugins surviving an online, the release reaching the persisted model, and —
// via a host that invalidates from inside a write — that a re-entered invalidation
// reaches nothing the drain is using.
// reaches nothing the drain is using. The queue owns the project pin and the timer's
// wakeup for the same reason: a compare-only pointer makes the tab switch assertable.
#include "../src/shell/view/fx_reclaim.h"
#include <cstdint>
#include <cstdio>
#include <map>
#include <optional>
@@ -176,6 +178,11 @@ FxReclaimQueue queueOf(const ViewModeModel& vm, const std::set<std::string>& liv
return q;
}
// Two project handles. The queue treats a project pointer as COMPARE-ONLY and never
// dereferences it, so a fabricated address is a faithful stand-in for REAPER's.
ReaProject* const kProjA = reinterpret_cast<ReaProject*>(static_cast<std::uintptr_t>(0x1));
ReaProject* const kProjB = reinterpret_cast<ReaProject*>(static_cast<std::uintptr_t>(0x2));
} // namespace
// -- 1. debounce ---------------------------------------------------------------
@@ -633,6 +640,138 @@ static void testAnOfflineTickReportsNoDrops() {
CHECK(r.drops.empty());
}
// -- 8. the pin and the wakeup ------------------------------------------------------
static void testATabSwitchRetargetsTheQueueAndLeavesTheWakeupStanding() {
// Project A, one parked leaf owed offline work, derived against A and pinned to it.
ViewModeModel a = parkedDesignLeaf({"{FX-A}"});
FakeHost hostA;
hostA.chains["{T}"] = onlineChain({"{FX-A}"});
FxReclaimQueue q;
q.invalidate(); // what applyMode does on its way out of the switch that parked {T}
CHECK(q.pending());
CHECK(q.beginTick(kProjA));
CHECK(q.project() == kProjA);
q.adopt(derive(a, {"{T}"}));
CHECK(!q.run(hostA).settled); // observes the hold at t=0; still owed
CHECK(!q.stale());
// The tab switch. poll()'s Load reapply invalidates on the way in, and NOTHING there
// moves the pin, so the tick arrives holding A's work with B active.
q.invalidate();
CHECK(q.beginTick(kProjB)); // the tick proceeds — it does not bail on the mismatch
CHECK(q.pending()); // and the timer will still call it: the strand's one bit
CHECK(q.project() == kProjB);
CHECK(q.stale()); // A's work is gone, B's is owed
CHECK(q.items().empty());
// B's own parked leaf converges on this very tick and every one after it.
ViewModeModel b = parkedDesignLeaves({{"{T2}", {"{FX-B}"}}});
FakeHost hostB;
hostB.chains["{T2}"] = onlineChain({"{FX-B}"});
q.adopt(derive(b, {"{T2}"}));
CHECK(!q.run(hostB).settled); // B's own hold starts here
hostB.clock += kParkHoldMs;
const ReclaimRunResult done = q.run(hostB);
CHECK(done.settled);
CHECK(hostB.writeLog == std::vector<std::string>{"{T2}:0=off"});
CHECK(hostB.offlineAt("{T2}", 0));
// ...and A's chain was never touched from B: the queue was dropped, not run.
CHECK(hostA.writeLog.empty());
// Only now, settled, does the shell stop the ticks.
q.goIdle();
CHECK(!q.pending());
CHECK(q.project() == nullptr);
}
static void testGoingIdleKeepsTheParkHoldWhileARetargetDropsIt() {
// A hold is evidence about a track in ONE project, so a tab switch drops it. An
// ordinary settle does not: restarting every 3s hold on the next unrelated membership
// edit would defer an unchanged track's reclaim indefinitely.
ViewModeModel vm = parkedDesignLeaf({"{FX-A}"});
FakeHost host;
host.chains["{T}"] = onlineChain({"{FX-A}"});
FxReclaimQueue q;
CHECK(q.beginTick(kProjA));
q.adopt(derive(vm, {"{T}"}));
q.run(host); // hold starts at t=0
host.clock = kParkHoldMs;
CHECK(q.run(host).settled);
CHECK(host.writeLog.size() == 1);
// Settled: the queue and the pin go, the hold stays. A membership edit elsewhere
// re-derives the SAME offline item, which is matured on arrival — one tick, no
// second 3s wait, and nothing to write because the chain is already there.
q.goIdle();
q.invalidate();
CHECK(q.beginTick(kProjA));
q.adopt(derive(vm, {"{T}"}));
const ReclaimRunResult resumed = q.run(host);
CHECK(resumed.settled);
CHECK(resumed.writes == 0);
CHECK(host.writeLog.size() == 1);
// The mirror: away to B and back to A, and the same item must wait a fresh hold.
q.invalidate();
CHECK(q.beginTick(kProjB));
CHECK(q.beginTick(kProjA));
q.adopt(derive(vm, {"{T}"}));
const double back = host.clock;
CHECK(!q.run(host).settled); // the hold restarts here, at `back`
host.clock = back + kParkHoldMs - 1.0;
CHECK(!q.run(host).settled);
host.clock = back + kParkHoldMs;
CHECK(q.run(host).settled);
}
static void testOnlyANoProjectTickClearsTheWakeup() {
ViewModeModel vm = parkedDesignLeaf({"{FX-A}"});
FakeHost host;
host.chains["{T}"] = onlineChain({"{FX-A}"});
FxReclaimQueue q;
q.invalidate();
CHECK(q.beginTick(kProjA));
q.adopt(derive(vm, {"{T}"}));
q.run(host);
host.clock += kParkHoldMs;
// An abort leaves the wakeup up: the work is re-owed, and the shell's settle test
// never runs on this path.
host.reentrant = &q;
host.invalidateAfterWrite = 1;
const ReclaimRunResult r = q.run(host);
CHECK(r.aborted);
CHECK(q.pending());
// No active project at all — the one arm with nothing to converge and nothing to
// come back to. Everything goes, wakeup included.
CHECK(!q.beginTick(nullptr));
CHECK(!q.pending());
CHECK(q.project() == nullptr);
CHECK(q.stale());
CHECK(q.items().empty());
}
static void testATickThatLostNothingNeitherPrintsNorDisturbsTheDropMemo() {
const std::vector<TrackFxDrops> none;
const std::vector<TrackFxDrops> lost{TrackFxDrops{"{T}", FxRestoreDrops{1, 0, 0}}};
CHECK(shouldReportDrops(/*sameOwnerAsLast=*/false, none, lost)); // first sighting
CHECK(!shouldReportDrops(/*sameOwnerAsLast=*/true, lost, lost)); // the re-derivation
// The regression: every ordinary tick carries no drops at all. Treating that as a
// change would overwrite the memo and re-arm the identical report for the next
// re-derivation — the shell holds the memo back on exactly this answer.
CHECK(!shouldReportDrops(/*sameOwnerAsLast=*/true, lost, none));
CHECK(!shouldReportDrops(/*sameOwnerAsLast=*/false, lost, none));
CHECK(!shouldReportDrops(/*sameOwnerAsLast=*/true, lost, lost));
}
int main() {
testFastFlipMovesZeroPluginsButAHeldParkStillReclaims();
@@ -657,6 +796,11 @@ int main() {
testADropIsReportedOnceAcrossRepeatedRederivations();
testAnOfflineTickReportsNoDrops();
testATabSwitchRetargetsTheQueueAndLeavesTheWakeupStanding();
testGoingIdleKeepsTheParkHoldWhileARetargetDropsIt();
testOnlyANoProjectTickClearsTheWakeup();
testATickThatLostNothingNeitherPrintsNorDisturbsTheDropMemo();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}