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
+14 -34
View File
@@ -30,12 +30,9 @@ namespace reasampler {
namespace {
// The derived queue and the project it was derived against. The pointer is
// COMPARE-ONLY and never dereferenced beyond the ValidatePtr2 the tick already makes —
// a tab switch has to be detectable without following a pointer that may name a closed
// project.
// The derived queue, the project it was derived against, and the timer's wakeup — see
// FxReclaimQueue, which owns all three.
FxReclaimQueue g_queue;
ReaProject* g_project = nullptr;
// 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
@@ -86,9 +83,11 @@ struct ReclaimHost {
std::string cachedGuid_;
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) {
if (guid == cachedGuid_ && cachedTrack_ &&
ValidatePtr2(nullptr, cachedTrack_, "MediaTrack*"))
ValidatePtr2(project, cachedTrack_, "MediaTrack*"))
return cachedTrack_;
cachedTrack_ = resolveFresh(project, guid);
cachedGuid_ = guid;
@@ -171,24 +170,15 @@ void reportDrops(ReaProject* proj, const std::vector<TrackFxDrops>& drops) {
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
bool g_fxReclaimPending = false;
bool fxReclaimPending() { return g_queue.pending(); }
void invalidateFxReclaim() {
// 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
// through, and clear the very wakeup this call had just raised.
g_queue.invalidate();
g_fxReclaimPending = true;
}
bool fxReclaimDrainInProgress() { return g_draining; }
@@ -200,27 +190,17 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) {
if (modeApplyInProgress()) return;
DrainLatch latch;
// Belt-and-braces, not the tab-switch mechanism: OnTimer runs session.poll() before
// this tick, and poll() already classifies a tab switch as a Load whose reapply
// invalidates the queue — so by the time this test runs, a switch has normally
// already been handled and g_project already matches. This still catches it when
// poll() was itself skipped this tick (modeApplyInProgress/drain-in-progress
// deferral) and the two go stale together. Both arms below mean the same thing —
// no active project at all, or a project this queue no longer belongs to — so drop
// 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;
}
// beginTick owns what a pin/project mismatch means. What is local to here: the model
// the re-derivation below reads is the ACTIVE project's, because reaching this line
// means OnTimer ran session.poll() this tick — the two guards above are the same pair
// that gate that poll.
ReaProject* project = proj ? proj : EnumProjects(-1, nullptr, 0);
if (!ValidatePtr2(nullptr, project, "ReaProject*")) project = nullptr;
if (!g_queue.beginTick(project)) return;
// 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.
if (g_queue.stale()) {
g_project = project;
const ViewModeModel& model = session.view();
g_queue.adopt(makeQueue(
view::desiredFxState(model, model.activeModeId(), liveTrackGuids(project))));
@@ -229,7 +209,7 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) {
ReclaimHost host{project, &session};
const ReclaimRunResult run = g_queue.run(host);
reportDrops(project, run.drops);
if (!run.aborted && run.settled) goIdle();
if (!run.aborted && run.settled) g_queue.goIdle();
}
} // 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.
// The one-per-changed-set memo this gates is view_park's `shouldReport`/
// `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,
const std::vector<TrackFxDrops>& now) {
if (now.empty()) return false;
return !(sameOwnerAsLast && last == now);
}
@@ -256,18 +262,64 @@ struct ReclaimRunResult {
std::vector<TrackFxDrops> drops;
};
// Owns the derived queue and the debounce clock across a tick that can be re-entered:
// TrackFX_SetOffline is ASSUMED to pump the message loop, so an invalidation can arrive
// with a drain half-way through a track.
// Owns the derived queue, the debounce clock, the project both were derived against, and
// the timer's wakeup — together, because it is their interaction that decides whether a
// 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
// counter and touches NOTHING else, so there is nothing for it to clear 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.
// The queue must also survive being re-entered: TrackFX_SetOffline is ASSUMED to pump the
// message loop, so an invalidation can arrive with a drain half-way through a track. The
// two halves of that safety are structural, not guarded. `invalidate()` bumps a counter
// and raises the wakeup and touches NOTHING else, so there is nothing for it to clear
// 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 {
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.
bool stale() const { return !derived_ || derivedAt_ != generation_; }
@@ -283,8 +335,6 @@ public:
derived_ = false;
}
void forgetHolds() { clock_.forget(); }
const std::vector<ReclaimItem>& items() const { return items_; }
template <class Host>
@@ -327,19 +377,22 @@ public:
private:
std::vector<ReclaimItem> items_;
ParkClock clock_;
ReaProject* project_ = nullptr;
unsigned long long generation_ = 0;
unsigned long long derivedAt_ = 0;
bool derived_ = false;
bool pending_ = false;
};
// --- shell entry points --------------------------------------------------------
// 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.
extern bool g_fxReclaimPending;
bool fxReclaimPending();
// Marks the derived queue for re-derivation by the NEXT tick; discards nothing here,
// because this is reachable from inside a drain. Called from applyMode and nothing else
// Raises the wakeup and marks the derived queue for re-derivation by the NEXT tick;
// 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
// 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
@@ -351,11 +404,12 @@ void invalidateFxReclaim();
bool fxReclaimDrainInProgress();
// 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
// project while an applyMode is on the stack, while already draining, or when the active
// project is not the one the queue was derived against. Takes the SESSION rather than the
// model alone because completing an online spends the park record's FX half, and that
// spend has to reach the persisted view state — see FxReclaimQueue::run.
// the session's view model first if it was invalidated or if the active project moved
// (FxReclaimQueue::beginTick). Returns without touching the project while an applyMode is
// on the stack, while already draining, or when there is no valid active project at all.
// Takes the SESSION rather than the model alone because completing an online spends the
// park record's FX half, and that spend has to reach the persisted view state — see
// FxReclaimQueue::run.
void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj);
} // namespace reasampler