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
+2 -2
View File
@@ -222,11 +222,11 @@ static void OnTimer()
// Converge the FX chains toward what the view state says they should be — the // Converge the FX chains toward what the view state says they should be — the
// 1897ms of TrackFX_SetOffline a mode switch no longer pays synchronously. Same // 1897ms of TrackFX_SetOffline a mode switch no longer pays synchronously. Same
// idle discipline as the realtime fast path above: a SINGLE BOOL TEST when there is // idle discipline as the realtime fast path above: ONE bool read when there is
// nothing to converge. Ordered AFTER the load block deliberately — a reload replaces // nothing to converge. Ordered AFTER the load block deliberately — a reload replaces
// the view model wholesale, and the applyMode above is what re-derives the queue // the view model wholesale, and the applyMode above is what re-derives the queue
// from it, so draining first would spend a tick against the outgoing model. // from it, so draining first would spend a tick against the outgoing model.
if (reasampler::g_fxReclaimPending) if (reasampler::fxReclaimPending())
reasampler::fxReclaimTick(g_session, nullptr); reasampler::fxReclaimTick(g_session, nullptr);
reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
+14 -34
View File
@@ -30,12 +30,9 @@ namespace reasampler {
namespace { namespace {
// The derived queue and the project it was derived against. The pointer is // The derived queue, the project it was derived against, and the timer's wakeup — see
// COMPARE-ONLY and never dereferenced beyond the ValidatePtr2 the tick already makes — // FxReclaimQueue, which owns all three.
// a tab switch has to be detectable without following a pointer that may name a closed
// project.
FxReclaimQueue g_queue; FxReclaimQueue g_queue;
ReaProject* g_project = nullptr;
// TrackFX_SetOffline is ASSUMED to pump the message loop, so a plugin load can re-enter // TrackFX_SetOffline is ASSUMED to pump the message loop, so a plugin load can re-enter
// the timer and land back here mid-drain. Fail closed: a re-entered drain would derive a // the timer and land back here mid-drain. Fail closed: a re-entered drain would derive a
@@ -86,9 +83,11 @@ struct ReclaimHost {
std::string cachedGuid_; std::string cachedGuid_;
MediaTrack* cachedTrack_ = nullptr; MediaTrack* cachedTrack_ = nullptr;
// A track is validated IN a project (reaper_plugin_functions.h:7898); the null form
// is defined only where the pointer is itself a project.
MediaTrack* resolveForStep(const std::string& guid) { MediaTrack* resolveForStep(const std::string& guid) {
if (guid == cachedGuid_ && cachedTrack_ && if (guid == cachedGuid_ && cachedTrack_ &&
ValidatePtr2(nullptr, cachedTrack_, "MediaTrack*")) ValidatePtr2(project, cachedTrack_, "MediaTrack*"))
return cachedTrack_; return cachedTrack_;
cachedTrack_ = resolveFresh(project, guid); cachedTrack_ = resolveFresh(project, guid);
cachedGuid_ = guid; cachedGuid_ = guid;
@@ -171,24 +170,15 @@ void reportDrops(ReaProject* proj, const std::vector<TrackFxDrops>& drops) {
if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str()); if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str());
} }
// Every path that stops scheduling ticks leaves the same state behind: no queue, no pin,
// nothing pending. The debounce clock is NOT part of it — see fx_reclaim.h.
void goIdle() {
g_queue.discard();
g_project = nullptr;
g_fxReclaimPending = false;
}
} // namespace } // namespace
bool g_fxReclaimPending = false; bool fxReclaimPending() { return g_queue.pending(); }
void invalidateFxReclaim() { void invalidateFxReclaim() {
// A generation bump and a flag, and deliberately nothing more. Discarding the queue // A generation bump and a flag, and deliberately nothing more. Discarding the queue
// here is what let a re-entered apply free the element the outer drain was writing // here is what let a re-entered apply free the element the outer drain was writing
// through, and clear the very wakeup this call had just raised. // through, and clear the very wakeup this call had just raised.
g_queue.invalidate(); g_queue.invalidate();
g_fxReclaimPending = true;
} }
bool fxReclaimDrainInProgress() { return g_draining; } bool fxReclaimDrainInProgress() { return g_draining; }
@@ -200,27 +190,17 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) {
if (modeApplyInProgress()) return; if (modeApplyInProgress()) return;
DrainLatch latch; DrainLatch latch;
// Belt-and-braces, not the tab-switch mechanism: OnTimer runs session.poll() before // beginTick owns what a pin/project mismatch means. What is local to here: the model
// this tick, and poll() already classifies a tab switch as a Load whose reapply // the re-derivation below reads is the ACTIVE project's, because reaching this line
// invalidates the queue — so by the time this test runs, a switch has normally // means OnTimer ran session.poll() this tick — the two guards above are the same pair
// already been handled and g_project already matches. This still catches it when // that gate that poll.
// poll() was itself skipped this tick (modeApplyInProgress/drain-in-progress ReaProject* project = proj ? proj : EnumProjects(-1, nullptr, 0);
// deferral) and the two go stale together. Both arms below mean the same thing — if (!ValidatePtr2(nullptr, project, "ReaProject*")) project = nullptr;
// no active project at all, or a project this queue no longer belongs to — so drop if (!g_queue.beginTick(project)) return;
// the queue rather than write another project's chains; returning to it re-derives.
// The clock goes too — a hold measured in a project we stopped watching is not
// evidence about the one we come back to.
ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0);
if (!ValidatePtr2(nullptr, project, "ReaProject*") || (g_project && g_project != project)) {
goIdle();
g_queue.forgetHolds();
return;
}
// DERIVED, never persisted: recomputed from view state every time the queue was // DERIVED, never persisted: recomputed from view state every time the queue was
// invalidated, so a REAPER quit mid-drain simply re-derives and finishes on reopen. // invalidated, so a REAPER quit mid-drain simply re-derives and finishes on reopen.
if (g_queue.stale()) { if (g_queue.stale()) {
g_project = project;
const ViewModeModel& model = session.view(); const ViewModeModel& model = session.view();
g_queue.adopt(makeQueue( g_queue.adopt(makeQueue(
view::desiredFxState(model, model.activeModeId(), liveTrackGuids(project)))); view::desiredFxState(model, model.activeModeId(), liveTrackGuids(project))));
@@ -229,7 +209,7 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) {
ReclaimHost host{project, &session}; ReclaimHost host{project, &session};
const ReclaimRunResult run = g_queue.run(host); const ReclaimRunResult run = g_queue.run(host);
reportDrops(project, run.drops); reportDrops(project, run.drops);
if (!run.aborted && run.settled) goIdle(); if (!run.aborted && run.settled) g_queue.goIdle();
} }
} // namespace reasampler } // namespace reasampler
+73 -19
View File
@@ -137,8 +137,14 @@ struct TrackFxDrops {
// True when a drop set is worth printing: any change of owner or of the set itself. // True when a drop set is worth printing: any change of owner or of the set itself.
// The one-per-changed-set memo this gates is view_park's `shouldReport`/ // The one-per-changed-set memo this gates is view_park's `shouldReport`/
// `reportExposedTracks`, which states the reason; the shell holds the memo. // `reportExposedTracks`, which states the reason; the shell holds the memo.
//
// A tick that lost nothing is never worth printing AND must leave the memo alone — it
// is not evidence about the last set that DID lose something, and letting it overwrite
// the memo re-arms the identical report for the next re-derivation to print again.
// Answering false is what holds the memo back, so this is the one gate for both.
inline bool shouldReportDrops(bool sameOwnerAsLast, const std::vector<TrackFxDrops>& last, inline bool shouldReportDrops(bool sameOwnerAsLast, const std::vector<TrackFxDrops>& last,
const std::vector<TrackFxDrops>& now) { const std::vector<TrackFxDrops>& now) {
if (now.empty()) return false;
return !(sameOwnerAsLast && last == now); return !(sameOwnerAsLast && last == now);
} }
@@ -256,18 +262,64 @@ struct ReclaimRunResult {
std::vector<TrackFxDrops> drops; std::vector<TrackFxDrops> drops;
}; };
// Owns the derived queue and the debounce clock across a tick that can be re-entered: // Owns the derived queue, the debounce clock, the project both were derived against, and
// TrackFX_SetOffline is ASSUMED to pump the message loop, so an invalidation can arrive // the timer's wakeup — together, because it is their interaction that decides whether a
// with a drain half-way through a track. // tick may happen at all. A `ReaProject*` here is COMPARE-ONLY and never dereferenced, so
// the whole state machine is exercisable without REAPER (tests/test_fx_reclaim.cpp).
// //
// The two halves of that safety are structural, not guarded. `invalidate()` bumps a // The queue must also survive being re-entered: TrackFX_SetOffline is ASSUMED to pump the
// counter and touches NOTHING else, so there is nothing for it to clear under a drain. // message loop, so an invalidation can arrive with a drain half-way through a track. The
// `run()` moves the queue and the clock into locals for the drain's whole duration, so // two halves of that safety are structural, not guarded. `invalidate()` bumps a counter
// the object holds nothing the drain is reading — and re-checks the counter before it // and raises the wakeup and touches NOTHING else, so there is nothing for it to clear
// commits either the surviving queue or a single release. // under a drain. `run()` moves the queue and the clock into locals for the drain's whole
// duration, so the object holds nothing the drain is reading — and re-checks the counter
// before it commits either the surviving queue or a single release.
class FxReclaimQueue { class FxReclaimQueue {
public: public:
void invalidate() { ++generation_; } void invalidate() {
++generation_;
pending_ = true;
}
// The ONE test the timer makes per tick. False means settled: nothing to converge
// until the next invalidation.
bool pending() const { return pending_; }
// The project the current queue and holds were derived against, or nullptr when idle.
ReaProject* project() const { return project_; }
// The tick's entry transition, and the ONE place the pin moves. `project` is the
// validated active project, or nullptr when there is none; false means the caller
// must return without touching REAPER.
//
// A project that does not match the pin is a TAB SWITCH — the ordinary path, not a
// corner. Neither the derived work nor the holds are evidence about the project we
// arrived in, so both go, and the tick proceeds to re-derive against the new one. The
// WAKEUP STANDS: clearing it here (an earlier goIdle in this branch did) stranded the
// incoming project's whole reclaim, because the timer gates every tick on it and
// nothing re-raises it short of a mode switch or a membership edit there. Only the
// no-project arm may clear it — nothing to converge, and nothing to come back to.
bool beginTick(ReaProject* project) {
if (!project) {
goIdle();
clock_.forget();
return false;
}
if (project_ && project_ != project) {
discard();
clock_.forget();
}
project_ = project;
return true;
}
// Settled: drop the queue and the pin and stop the timer's ticks. The debounce clock
// deliberately survives — it is keyed on the track, not on the queue.
void goIdle() {
discard();
project_ = nullptr;
pending_ = false;
}
// No queue at all, or one derived before the last invalidation. // No queue at all, or one derived before the last invalidation.
bool stale() const { return !derived_ || derivedAt_ != generation_; } bool stale() const { return !derived_ || derivedAt_ != generation_; }
@@ -283,8 +335,6 @@ public:
derived_ = false; derived_ = false;
} }
void forgetHolds() { clock_.forget(); }
const std::vector<ReclaimItem>& items() const { return items_; } const std::vector<ReclaimItem>& items() const { return items_; }
template <class Host> template <class Host>
@@ -327,19 +377,22 @@ public:
private: private:
std::vector<ReclaimItem> items_; std::vector<ReclaimItem> items_;
ParkClock clock_; ParkClock clock_;
ReaProject* project_ = nullptr;
unsigned long long generation_ = 0; unsigned long long generation_ = 0;
unsigned long long derivedAt_ = 0; unsigned long long derivedAt_ = 0;
bool derived_ = false; bool derived_ = false;
bool pending_ = false;
}; };
// --- shell entry points -------------------------------------------------------- // --- shell entry points --------------------------------------------------------
// The ONE test the timer makes per tick, mirroring the realtime capture's single // The ONE test the timer makes per tick, mirroring the realtime capture's single
// pointer test. False means the queue is settled and there is nothing to converge. // pointer test. False means the queue is settled and there is nothing to converge.
extern bool g_fxReclaimPending; bool fxReclaimPending();
// Marks the derived queue for re-derivation by the NEXT tick; discards nothing here, // Raises the wakeup and marks the derived queue for re-derivation by the NEXT tick;
// because this is reachable from inside a drain. Called from applyMode and nothing else // discards nothing here, because this is reachable from inside a drain. Called from
// applyMode and nothing else
// — every membership, snapshot and active-mode mutation reapplies through it — on the // — every membership, snapshot and active-mode mutation reapplies through it — on the
// refusal path as well as the success one (see view.cpp for why the refusal is the // refusal path as well as the success one (see view.cpp for why the refusal is the
// load-bearing half). The debounce clock deliberately survives: it is keyed on the // load-bearing half). The debounce clock deliberately survives: it is keyed on the
@@ -351,11 +404,12 @@ void invalidateFxReclaim();
bool fxReclaimDrainInProgress(); bool fxReclaimDrainInProgress();
// Drains one tick against `proj` (nullptr = current project), re-deriving the queue from // Drains one tick against `proj` (nullptr = current project), re-deriving the queue from
// the session's view model first if it was invalidated. Returns without touching the // the session's view model first if it was invalidated or if the active project moved
// project while an applyMode is on the stack, while already draining, or when the active // (FxReclaimQueue::beginTick). Returns without touching the project while an applyMode is
// project is not the one the queue was derived against. Takes the SESSION rather than the // on the stack, while already draining, or when there is no valid active project at all.
// model alone because completing an online spends the park record's FX half, and that // Takes the SESSION rather than the model alone because completing an online spends the
// spend has to reach the persisted view state — see FxReclaimQueue::run. // park record's FX half, and that spend has to reach the persisted view state — see
// FxReclaimQueue::run.
void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj); void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj);
} // namespace reasampler } // namespace reasampler
+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 // 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 — // 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 // 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 "../src/shell/view/fx_reclaim.h"
#include <cstdint>
#include <cstdio> #include <cstdio>
#include <map> #include <map>
#include <optional> #include <optional>
@@ -176,6 +178,11 @@ FxReclaimQueue queueOf(const ViewModeModel& vm, const std::set<std::string>& liv
return q; 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 } // namespace
// -- 1. debounce --------------------------------------------------------------- // -- 1. debounce ---------------------------------------------------------------
@@ -633,6 +640,138 @@ static void testAnOfflineTickReportsNoDrops() {
CHECK(r.drops.empty()); 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() { int main() {
testFastFlipMovesZeroPluginsButAHeldParkStillReclaims(); testFastFlipMovesZeroPluginsButAHeldParkStillReclaims();
@@ -657,6 +796,11 @@ int main() {
testADropIsReportedOnceAcrossRepeatedRederivations(); testADropIsReportedOnceAcrossRepeatedRederivations();
testAnOfflineTickReportsNoDrops(); testAnOfflineTickReportsNoDrops();
testATabSwitchRetargetsTheQueueAndLeavesTheWakeupStanding();
testGoingIdleKeepsTheParkHoldWhileARetargetDropsIt();
testOnlyANoProjectTickClearsTheWakeup();
testATickThatLostNothingNeitherPrintsNorDisturbsTheDropMemo();
if (g_fail == 0) std::printf("All tests passed.\n"); if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0; return g_fail ? 1 : 0;
} }