Reclaim drain owns its work: invalidation only bumps a generation, and a generation that moved aborts the tick's commit and its release.

This commit is contained in:
2026-08-06 21:08:34 -04:00
parent f04c704353
commit 5dcfd3a53f
7 changed files with 321 additions and 68 deletions
+10 -6
View File
@@ -189,7 +189,10 @@ static void OnTimer()
// this timer mid-apply), same retry-next-tick shape as the consume guard below. // this timer mid-apply), same retry-next-tick shape as the consume guard below.
// Deferred WHOLE, not just the reload branches: poll()'s identity tracking is // Deferred WHOLE, not just the reload branches: poll()'s identity tracking is
// cheap to skip for one tick and resumes correctly, unlatched, on the next. // cheap to skip for one tick and resumes correctly, unlatched, on the next.
if (!reasampler::modeApplyInProgress()) g_session.poll(); // The reclaim drain is latched for the same reason and is the same window: it
// holds that view model for the length of a plugin unload, which pumps.
if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress())
g_session.poll();
// persist stays MODEL-ONLY (loads the saved view model but does not apply // persist stays MODEL-ONLY (loads the saved view model but does not apply
// visibility, to avoid coupling persist to the view shell); poll() raises a // visibility, to avoid coupling persist to the view shell); poll() raises a
@@ -198,11 +201,12 @@ static void OnTimer()
// signal re-arms the bank panel's new-content detector — notified BEFORE the // signal re-arms the bank panel's new-content detector — notified BEFORE the
// reapply so re-arm and model restore ride the one load event (otherwise // reapply so re-arm and model restore ride the one load event (otherwise
// pre-existing tracks can be mis-detected as "new" and mass-tagged). // pre-existing tracks can be mis-detected as "new" and mass-tagged).
// modeApplyInProgress is tested BEFORE the consume, not after the applyMode call: // Both latches are tested BEFORE the consume, not after the applyMode call: the load
// the load signal is one-shot, a re-entered applyMode fails closed, and a reapply // signal is one-shot, a re-entered applyMode fails closed, and a reapply dropped here
// dropped here is the saved mode never applied at all. Holding the whole block // is the saved mode never applied at all. Holding the whole block back one tick also
// back one tick also keeps the panel re-arm ahead of the reapply. // keeps the panel re-arm ahead of the reapply.
if (!reasampler::modeApplyInProgress() && g_session.consumeLoadSignal()) { if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress() &&
g_session.consumeLoadSignal()) {
reasampler::bankPanelNotifyProjectLoaded(); reasampler::bankPanelNotifyProjectLoaded();
// Reconcile lane ownership against the live project's lanes (P_LANENAME, // Reconcile lane ownership against the live project's lanes (P_LANENAME,
// the cross-session source of truth) BEFORE reapplying visibility. Never // the cross-session source of truth) BEFORE reapplying visibility. Never
+4 -1
View File
@@ -104,7 +104,10 @@ std::vector<std::string> selectedTrackGuids() {
} }
// Reapplies the CURRENT active mode so a membership mutation takes visible effect // Reapplies the CURRENT active mode so a membership mutation takes visible effect
// immediately (park/unpark/re-derive parents). // immediately (park/unpark/re-derive parents). The return is deliberately discarded: a
// refused reapply leaves the membership edit standing and unapplied, and the user's next
// edit or mode switch reapplies it. What must NOT be dropped with it is the reclaimer's
// queue, which is why applyMode invalidates on its refusal path too (view.cpp).
void reapplyActiveMode() { void reapplyActiveMode() {
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr); applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
} }
+19 -13
View File
@@ -32,14 +32,12 @@ namespace {
// COMPARE-ONLY and never dereferenced beyond the ValidatePtr2 the tick already makes — // 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 // a tab switch has to be detectable without following a pointer that may name a closed
// project. // project.
std::vector<ReclaimItem> g_items; FxReclaimQueue g_queue;
ReaProject* g_project = nullptr; ReaProject* g_project = nullptr;
ParkClock g_clock;
// 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 walk the // the timer and land back here mid-drain. Fail closed: a re-entered drain would derive a
// same queue the outer one holds a reference into, re-running the very item that outer // second queue for the same project and race the outer one's writes track for track.
// step is still writing.
bool g_draining = false; bool g_draining = false;
struct DrainLatch { struct DrainLatch {
DrainLatch() { g_draining = true; } DrainLatch() { g_draining = true; }
@@ -112,13 +110,15 @@ struct ReclaimHost {
return true; return true;
} }
// Reached only after the drain has unwound and the queue has proved its generation
// did not move, so `model` is still the object the queue was derived against.
void releaseFx(const std::string& guid) { model->releaseSnapshotFx(guid); } void releaseFx(const std::string& guid) { model->releaseSnapshotFx(guid); }
}; };
// Every path that stops scheduling ticks leaves the same state behind: no queue, no pin, // 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. // nothing pending. The debounce clock is NOT part of it — see fx_reclaim.h.
void goIdle() { void goIdle() {
g_items.clear(); g_queue.discard();
g_project = nullptr; g_project = nullptr;
g_fxReclaimPending = false; g_fxReclaimPending = false;
} }
@@ -128,10 +128,15 @@ void goIdle() {
bool g_fxReclaimPending = false; bool g_fxReclaimPending = false;
void invalidateFxReclaim() { void invalidateFxReclaim() {
goIdle(); // 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; g_fxReclaimPending = true;
} }
bool fxReclaimDrainInProgress() { return g_draining; }
void fxReclaimTick(ViewModeModel& model, ReaProject* proj) { void fxReclaimTick(ViewModeModel& model, ReaProject* proj) {
if (g_draining) return; if (g_draining) return;
// The model is mid-rewrite and its records are being consumed out from under any // The model is mid-rewrite and its records are being consumed out from under any
@@ -148,20 +153,21 @@ void fxReclaimTick(ViewModeModel& model, ReaProject* proj) {
ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0); ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0);
if (!ValidatePtr2(nullptr, project, "ReaProject*") || (g_project && g_project != project)) { if (!ValidatePtr2(nullptr, project, "ReaProject*") || (g_project && g_project != project)) {
goIdle(); goIdle();
g_clock.forget(); g_queue.forgetHolds();
return; 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
// discarded, 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_project) { if (g_queue.stale()) {
g_project = project; g_project = project;
g_items = makeQueue( g_queue.adopt(makeQueue(
view::desiredFxState(model, model.activeModeId(), liveTrackGuids(project))); view::desiredFxState(model, model.activeModeId(), liveTrackGuids(project))));
} }
ReclaimHost host{project, &model}; ReclaimHost host{project, &model};
if (drainTick(g_items, g_clock, host).settled) goIdle(); const ReclaimRunResult run = g_queue.run(host);
if (!run.aborted && run.settled) goIdle();
} }
} // namespace reasampler } // namespace reasampler
+111 -13
View File
@@ -92,6 +92,9 @@ public:
void forget() { since_.clear(); } void forget() { since_.clear(); }
// The owner swaps the clock out for the duration of a drain — see FxReclaimQueue.
void swap(ParkClock& other) { since_.swap(other.since_); }
private: private:
std::map<std::string, double> since_; std::map<std::string, double> since_;
}; };
@@ -119,6 +122,12 @@ inline int nextEligible(const std::vector<ReclaimItem>& items, const ParkClock&
struct ReclaimTickResult { struct ReclaimTickResult {
int writes = 0; int writes = 0;
bool settled = false; bool settled = false;
// Tracks whose park record the tick drove fully online, so the record's FX half is
// spent. REPORTED, not applied: releasing is a model write and the model is exactly
// what a re-entered load can replace mid-drain, so only the owner may commit it —
// see FxReclaimQueue::run.
std::vector<std::string> released;
}; };
// One tick of the convergence loop. Templated on `Host` so the ordering, budget and // One tick of the convergence loop. Templated on `Host` so the ordering, budget and
@@ -131,7 +140,14 @@ struct ReclaimTickResult {
// bool writeOffline(guid, fxIndex, offline) compare-before-write inside its // bool writeOffline(guid, fxIndex, offline) compare-before-write inside its
// own discarded undo block; // own discarded undo block;
// true iff it actually wrote // true iff it actually wrote
// void releaseFx(guid) release the record's FX half // void releaseFx(guid) release the record's FX half;
// called by FxReclaimQueue::run
// after the drain has unwound,
// never from inside the drain
//
// `items` and `clock` must be the caller's OWN storage, not shared state a re-entered
// call could reach: a write pumps the message loop. FxReclaimQueue::run is what makes
// that true for the live loop.
template <class Host> template <class Host>
ReclaimTickResult drainTick(std::vector<ReclaimItem>& items, ParkClock& clock, Host& host) { ReclaimTickResult drainTick(std::vector<ReclaimItem>& items, ParkClock& clock, Host& host) {
ReclaimTickResult result; ReclaimTickResult result;
@@ -166,11 +182,12 @@ ReclaimTickResult drainTick(std::vector<ReclaimItem>& items, ParkClock& clock, H
for (const FxOfflineWrite& w : resolved.writes) for (const FxOfflineWrite& w : resolved.writes)
if (host.writeOffline(item.work.trackGuid, w.fxIndex, w.offline)) ++result.writes; if (host.writeOffline(item.work.trackGuid, w.fxIndex, w.offline)) ++result.writes;
// The record's FX half is released HERE and only here: the chain has now // The record is spent only once the chain has been driven to every state it
// been driven to every state the record captured, so the record is spent. // captured — releasing any earlier destroys the only account of which plugins
// Releasing it any earlier destroys the only account of which plugins the // the USER had offline before the park, and the next online blanket-enables
// USER had offline before the park, and the next online blanket-enables them. // them. Reported rather than applied; see ReclaimTickResult::released.
if (item.work.kind == view::FxWorkKind::Online) host.releaseFx(item.work.trackGuid); if (item.work.kind == view::FxWorkKind::Online)
result.released.push_back(item.work.trackGuid);
} }
item.done = true; // both arms: a step is atomic, so no item is half-moved item.done = true; // both arms: a step is atomic, so no item is half-moved
@@ -181,23 +198,104 @@ ReclaimTickResult drainTick(std::vector<ReclaimItem>& items, ParkClock& clock, H
return result; return result;
} }
// --- the queue's owner ----------------------------------------------------------
struct ReclaimRunResult {
int writes = 0;
bool settled = false;
// Something invalidated the queue while the drain was on the stack. The work and
// the releases are DISCARDED, `settled` is never true, and the caller must not go
// idle — the next tick re-derives against whatever changed.
bool aborted = false;
};
// 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.
//
// 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.
class FxReclaimQueue {
public:
void invalidate() { ++generation_; }
// No queue at all, or one derived before the last invalidation.
bool stale() const { return !derived_ || derivedAt_ != generation_; }
void adopt(std::vector<ReclaimItem> items) {
items_ = std::move(items);
derived_ = true;
derivedAt_ = generation_;
}
void discard() {
items_.clear();
derived_ = false;
}
void forgetHolds() { clock_.forget(); }
const std::vector<ReclaimItem>& items() const { return items_; }
template <class Host>
ReclaimRunResult run(Host& host) {
const unsigned long long entered = generation_;
std::vector<ReclaimItem> work;
ParkClock clock;
work.swap(items_);
clock.swap(clock_);
const ReclaimTickResult tick = drainTick(work, clock, host);
// The hold is keyed on the track and outlives any re-derivation, so it comes
// back whether or not the drain's own work survives.
clock_.swap(clock);
if (generation_ != entered) return ReclaimRunResult{tick.writes, false, true};
items_.swap(work);
// A release dropped by an abort is re-owed, not lost: the next derivation plans
// the same Online item, its writes all find the chain already there, and the
// release fires then.
for (const std::string& guid : tick.released) host.releaseFx(guid);
return ReclaimRunResult{tick.writes, tick.settled, false};
}
private:
std::vector<ReclaimItem> items_;
ParkClock clock_;
unsigned long long generation_ = 0;
unsigned long long derivedAt_ = 0;
bool derived_ = 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; extern bool g_fxReclaimPending;
// Discards the derived queue. Called from every path that can change what the FX chains // Marks the derived queue for re-derivation by the NEXT tick; discards nothing here,
// SHOULD be — which is applyMode's success path and nothing else, because every // because this is reachable from inside a drain. Called from applyMode and nothing else
// membership, snapshot and active-mode mutation reapplies through it. The debounce clock // — every membership, snapshot and active-mode mutation reapplies through it — on the
// deliberately survives: it is keyed on the track, not on the queue. // 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
// track, not on the queue.
void invalidateFxReclaim(); void invalidateFxReclaim();
// True while a drain is on the stack. Exposed so the timer's other work can stand off
// the model the drain is holding, the same way it stands off an applyMode.
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
// `model` first if it was invalidated. Returns without touching the project while an // `model` first if it was invalidated. Returns without touching the project while an
// applyMode is on the stack, while already draining (TrackFX_SetOffline is assumed to // applyMode is on the stack, while already draining, or when the active project is not
// pump the message loop), or when the active project is not the one the queue was // the one the queue was derived against.
// derived against.
void fxReclaimTick(ViewModeModel& model, ReaProject* proj); void fxReclaimTick(ViewModeModel& model, ReaProject* proj);
} // namespace reasampler } // namespace reasampler
+22 -6
View File
@@ -62,7 +62,9 @@ bool transportBlocksModeSwitch(ReaProject* proj) {
bool modeApplyInProgress() { return g_applying; } bool modeApplyInProgress() { return g_applying; }
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) { namespace {
bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
if (g_applying) return false; // see ApplyLatch; same fail-closed shape as the guards below if (g_applying) return false; // see ApplyLatch; same fail-closed shape as the guards below
ApplyLatch latch; ApplyLatch latch;
@@ -203,12 +205,26 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
// an undo rolling the model back underneath it. // an undo rolling the model back underneath it.
noteAppliedState(project, model); noteAppliedState(project, model);
// Every membership, snapshot and active-mode mutation in the system reaches the
// project through this call, so this is the whole invalidation surface: what the FX
// chains SHOULD be has just changed, and the reclaimer's queue is a cache of that
// intent, never state.
invalidateFxReclaim();
return true; return true;
} }
} // namespace
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
const bool applied = applyModeImpl(model, targetModeId, proj);
// Every membership, snapshot and active-mode mutation in the system reaches the
// project through this call, so this is the whole invalidation surface: the
// reclaimer's queue is a cache of what the FX chains SHOULD be, never state.
//
// Dropped on a REFUSAL too, and that is the load-bearing half. Callers mutate the
// model FIRST and reapply to make it visible (design_view_actions' tag/untag/
// show-both, render_in_place, the load tick), and every one of them discards this
// return — so a refusal is exactly the case where the mutation stands and nothing
// else would drop a queue that now converges toward the mode the user just left.
// The cost when the refusal followed no mutation is one re-derivation on the timer.
invalidateFxReclaim();
return applied;
}
} // namespace reasampler } // namespace reasampler
+3
View File
@@ -59,6 +59,9 @@ bool modeApplyInProgress();
// (modeApplyInProgress), or if the project does not validate. A reapply (target == // (modeApplyInProgress), or if the project does not validate. A reapply (target ==
// active) is never gated and never touches solo. `proj` == nullptr means the current // active) is never gated and never touches solo. `proj` == nullptr means the current
// project, resolved ONCE and used for every call this apply makes. // project, resolved ONCE and used for every call this apply makes.
//
// Either way — applied or refused — this drops the FX reclaimer's derived queue, which
// is why callers may safely discard the return after mutating the model.
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj); bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
} // namespace reasampler } // namespace reasampler
+151 -28
View File
@@ -1,8 +1,9 @@
// Standalone tests for the FX reclaimer's drain — no REAPER, no test framework. The // Standalone tests for the FX reclaimer's drain — no REAPER, no test framework. The
// loop is templated on its host precisely so the four properties that make it safe to // loop is templated on its host precisely so the properties that make it safe to run in
// run in the background are assertable here: the debounce, the per-tick budget's // the background are assertable here: the debounce, the per-tick budget's between-track
// between-track yield, the queue being a cache of intent that a model change discards, // yield, the queue being a cache of intent that a model change discards, the user's own
// and the user's own offline plugins surviving an online. // offline plugins surviving an online, and — via a host that invalidates from inside a
// write — that a re-entered invalidation reaches nothing the drain is using.
#include "../src/shell/view/fx_reclaim.h" #include "../src/shell/view/fx_reclaim.h"
@@ -39,6 +40,13 @@ struct FakeHost {
double clock = 0.0; double clock = 0.0;
double stepCostMs = 50.0; double stepCostMs = 50.0;
// Models a re-entered timer landing inside TrackFX_SetOffline's message pump: on the
// Nth successful write, the write itself does to the queue what a nested applyMode
// used to do — clear it and mark it stale.
FxReclaimQueue* reentrant = nullptr;
int invalidateAfterWrite = -1;
int itemsSeenMidDrain = -1; // queue size as the re-entrant caller finds it
double nowMs() const { return clock; } double nowMs() const { return clock; }
bool liveChain(const std::string& guid, std::vector<std::string>& out) { bool liveChain(const std::string& guid, std::vector<std::string>& out) {
@@ -58,6 +66,12 @@ struct FakeHost {
it->second[static_cast<std::size_t>(fxIndex)].offline = offline; it->second[static_cast<std::size_t>(fxIndex)].offline = offline;
clock += stepCostMs; clock += stepCostMs;
writeLog.push_back(guid + ":" + std::to_string(fxIndex) + (offline ? "=off" : "=on")); writeLog.push_back(guid + ":" + std::to_string(fxIndex) + (offline ? "=off" : "=on"));
if (reentrant && static_cast<int>(writeLog.size()) == invalidateAfterWrite) {
itemsSeenMidDrain = static_cast<int>(reentrant->items().size());
reentrant->discard();
reentrant->invalidate();
}
return true; return true;
} }
@@ -121,15 +135,29 @@ void matureHold(std::vector<ReclaimItem>& items, ParkClock& clock, FakeHost& hos
} }
// Runs ticks until the queue settles, bounded so a non-terminating drain fails the test // Runs ticks until the queue settles, bounded so a non-terminating drain fails the test
// rather than hanging CTest. // rather than hanging CTest. The two overloads are the two entry points: the pure drain
// over the caller's own storage, and the owned queue the shell actually runs.
int drainToSettled(std::vector<ReclaimItem>& items, ParkClock& clock, FakeHost& host, int drainToSettled(std::vector<ReclaimItem>& items, ParkClock& clock, FakeHost& host,
int maxTicks = 64) { int maxTicks = 64) {
for (int t = 1; t <= maxTicks; ++t) for (int t = 1; t <= maxTicks; ++t)
if (drainTick(items, clock, host).settled) return t; if (drainTick(items, clock, host).settled) return t;
CHECK(false);
return -1;
}
int drainToSettled(FxReclaimQueue& queue, FakeHost& host, int maxTicks = 64) {
for (int t = 1; t <= maxTicks; ++t)
if (queue.run(host).settled) return t;
CHECK(false); // did not settle — the drain is not making forward progress CHECK(false); // did not settle — the drain is not making forward progress
return -1; return -1;
} }
FxReclaimQueue queueOf(const ViewModeModel& vm, const std::set<std::string>& live) {
FxReclaimQueue q;
q.adopt(derive(vm, live));
return q;
}
} // namespace } // namespace
// -- 1. debounce --------------------------------------------------------------- // -- 1. debounce ---------------------------------------------------------------
@@ -139,21 +167,20 @@ static void testFastFlipMovesZeroPluginsButAHeldParkStillReclaims() {
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"}); ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"});
FakeHost host; FakeHost host;
host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"});
ParkClock clock;
std::vector<ReclaimItem> items = derive(vm, {"{T}"}); FxReclaimQueue q = queueOf(vm, {"{T}"});
CHECK(items.size() == 1 && items[0].work.kind == FxWorkKind::Offline); CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Offline);
CHECK(drainTick(items, clock, host).writes == 0); CHECK(q.run(host).writes == 0);
CHECK(!drainTick(items, clock, host).settled); // waiting out its hold, not finished CHECK(!q.run(host).settled); // waiting out its hold, not finished
// B→A half a second later: the flags come back and the FX half is still held, so the // B→A half a second later: the flags come back and the FX half is still held, so the
// reclaimer is now owed an ONLINE — over a chain it never got round to offlining. // reclaimer is now owed an ONLINE — over a chain it never got round to offlining.
host.clock = 500.0; host.clock = 500.0;
vm.setActiveMode(kDesignModeId); vm.setActiveMode(kDesignModeId);
vm.releaseSnapshotFlags("{T}"); vm.releaseSnapshotFlags("{T}");
items = derive(vm, {"{T}"}); q.adopt(derive(vm, {"{T}"}));
CHECK(items.size() == 1 && items[0].work.kind == FxWorkKind::Online); CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Online);
drainToSettled(items, clock, host); drainToSettled(q, host);
// The whole point of the 3s hold: a fast A→B→A flip moved NO plugins. // The whole point of the 3s hold: a fast A→B→A flip moved NO plugins.
CHECK(host.writeLog.empty()); CHECK(host.writeLog.empty());
@@ -165,11 +192,10 @@ static void testFastFlipMovesZeroPluginsButAHeldParkStillReclaims() {
ViewModeModel held = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"}); ViewModeModel held = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"});
FakeHost host2; FakeHost host2;
host2.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); host2.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"});
ParkClock clock2; FxReclaimQueue q2 = queueOf(held, {"{T}"});
std::vector<ReclaimItem> items2 = derive(held, {"{T}"}); CHECK(q2.run(host2).writes == 0); // observed parked at t=0
CHECK(drainTick(items2, clock2, host2).writes == 0); // observed parked at t=0
host2.clock = kParkHoldMs; host2.clock = kParkHoldMs;
drainToSettled(items2, clock2, host2); drainToSettled(q2, host2);
CHECK(host2.writeLog.size() == 3); CHECK(host2.writeLog.size() == 3);
CHECK(host2.offlineAt("{T}", 0) && host2.offlineAt("{T}", 1) && host2.offlineAt("{T}", 2)); CHECK(host2.offlineAt("{T}", 0) && host2.offlineAt("{T}", 1) && host2.offlineAt("{T}", 2));
CHECK(host2.released.empty()); // an OFFLINE never releases — the record is still owed CHECK(host2.released.empty()); // an OFFLINE never releases — the record is still owed
@@ -329,10 +355,9 @@ static void testAPluginTheUserOfflinedIsNeverOnlinedByTheDrain() {
FakeHost host; FakeHost host;
host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); // fully parked host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); // fully parked
ParkClock clock;
std::vector<ReclaimItem> items = derive(vm, {"{T}"}); FxReclaimQueue q = queueOf(vm, {"{T}"});
drainToSettled(items, clock, host); drainToSettled(q, host);
CHECK(!host.offlineAt("{T}", 0)); CHECK(!host.offlineAt("{T}", 0));
CHECK(host.offlineAt("{T}", 1)); // still offline — never blanket-onlined CHECK(host.offlineAt("{T}", 1)); // still offline — never blanket-onlined
@@ -360,11 +385,10 @@ static void testSlotKeyedOnlineRecordRestoresByPosition() {
// Dearer per plugin than the whole online budget: the step still runs the chain to // Dearer per plugin than the whole online budget: the step still runs the chain to
// the end, because the budget cannot split one track's move. // the end, because the budget cannot split one track's move.
host.stepCostMs = kOnlineBudgetMs + 10.0; host.stepCostMs = kOnlineBudgetMs + 10.0;
ParkClock clock;
std::vector<ReclaimItem> items = derive(vm, {"{T}"}); FxReclaimQueue q = queueOf(vm, {"{T}"});
CHECK(items.size() == 1); CHECK(q.items().size() == 1);
CHECK(drainToSettled(items, clock, host) == 1); CHECK(drainToSettled(q, host) == 1);
const std::vector<std::string> expected{"{T}:0=on", "{T}:2=on"}; const std::vector<std::string> expected{"{T}:0=on", "{T}:2=on"};
CHECK(host.writeLog == expected); // slot 1 stays offline, as the record captured CHECK(host.writeLog == expected); // slot 1 stays offline, as the record captured
CHECK(host.offlineAt("{T}", 1)); CHECK(host.offlineAt("{T}", 1));
@@ -373,15 +397,110 @@ static void testSlotKeyedOnlineRecordRestoresByPosition() {
static void testATrackThatLeftTheProjectCompletesRatherThanStalls() { static void testATrackThatLeftTheProjectCompletesRatherThanStalls() {
ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); ViewModeModel vm = parkedDesignLeaf({"{FX-A}"});
FakeHost host; // no chain registered ⇒ liveChain reports the track gone FakeHost host; // no chain registered ⇒ liveChain reports the track gone
ParkClock clock;
std::vector<ReclaimItem> items = derive(vm, {"{T}"}); FxReclaimQueue q = queueOf(vm, {"{T}"});
matureHold(items, clock, host); CHECK(!q.run(host).settled); // observes the hold at t=0; not matured yet
CHECK(drainTick(items, clock, host).settled); host.clock += kParkHoldMs;
CHECK(q.run(host).settled);
CHECK(host.writeLog.empty()); CHECK(host.writeLog.empty());
CHECK(host.released.empty()); // the next apply's reconcile drops the whole record CHECK(host.released.empty()); // the next apply's reconcile drops the whole record
} }
// -- 5. re-entrancy: an invalidation arriving with a drain on the stack ------------
static void testAReentrantInvalidationReachesNothingTheDrainIsUsing() {
// The second of {T1}'s four writes re-enters and does what a nested applyMode used to
// do to the shared queue: clear it. The drain is mid-item at that moment.
ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"}},
{"{T2}", {"{FX-E}"}}});
FakeHost host;
host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"});
host.chains["{T2}"] = onlineChain({"{FX-E}"});
host.stepCostMs = 50.0;
FxReclaimQueue q = queueOf(vm, {"{T1}", "{T2}"});
q.run(host); // observes both parked at t=0
host.clock += kParkHoldMs;
host.reentrant = &q;
host.invalidateAfterWrite = 2;
const ReclaimRunResult r = q.run(host);
// The ownership claim: for the whole length of the drain the queue object holds
// nothing, so there is no element for a re-entrant caller to destroy.
CHECK(host.itemsSeenMidDrain == 0);
// The drain finished the track it was on out of its own copy — a step is atomic.
const std::vector<std::string> whole{"{T1}:0=off", "{T1}:1=off", "{T1}:2=off",
"{T1}:3=off"};
CHECK(host.writeLog == whole);
CHECK(r.writes == 4);
// ...and then refused to commit work derived from a model that has since moved.
CHECK(r.aborted);
CHECK(!r.settled);
CHECK(q.stale());
CHECK(q.items().empty());
}
static void testAnInvalidationDuringTheLastItemLeavesTheTickUnsettled() {
// One item, so the drain would otherwise report settled and the shell would go idle —
// clearing the pending flag the re-entrant invalidation had just raised and stranding
// its work until the next mode switch.
ViewModeModel vm = parkedDesignLeaf({"{FX-A}"});
FakeHost host;
host.chains["{T}"] = onlineChain({"{FX-A}"});
FxReclaimQueue q = queueOf(vm, {"{T}"});
q.run(host);
host.clock += kParkHoldMs;
host.reentrant = &q;
host.invalidateAfterWrite = 1;
const ReclaimRunResult r = q.run(host);
CHECK(r.writes == 1);
CHECK(!r.settled); // the one bit that decides whether the shell clears its wakeup
CHECK(r.aborted);
CHECK(q.stale()); // and the next tick re-derives rather than finding an empty queue
// The contrast, so the assertion above is about the invalidation and not about this
// queue being unable to settle: the same drain settles once nothing re-enters it.
host.reentrant = nullptr;
q.adopt(derive(vm, {"{T}"}));
CHECK(q.run(host).settled);
}
static void testAnInvalidationDuringAnOnlineDropsThatTicksRelease() {
// Releasing spends the record's FX half — the only account of which plugins the USER
// had offline before the park. An invalidation mid-drain is the signal that the model
// underneath may have been replaced (a project load replaces it in place), so the
// release must not land on it.
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"});
vm.setActiveMode(kDesignModeId);
vm.releaseSnapshotFlags("{T}");
FakeHost host;
host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"});
FxReclaimQueue q = queueOf(vm, {"{T}"});
CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Online);
host.reentrant = &q;
host.invalidateAfterWrite = 1;
const ReclaimRunResult r = q.run(host);
CHECK(r.aborted);
CHECK(r.writes == 2); // the item still completed — a step is atomic
CHECK(host.released.empty()); // but its record was NOT spent
// Re-owed, not lost: the re-derived item finds the chain already where the record
// wants it, writes nothing more, and releases then.
host.reentrant = nullptr;
q.adopt(derive(vm, {"{T}"}));
CHECK(q.run(host).settled);
CHECK(host.writeLog.size() == 2);
CHECK(host.released.count("{T}") == 1);
}
int main() { int main() {
testFastFlipMovesZeroPluginsButAHeldParkStillReclaims(); testFastFlipMovesZeroPluginsButAHeldParkStillReclaims();
@@ -396,6 +515,10 @@ int main() {
testSlotKeyedOnlineRecordRestoresByPosition(); testSlotKeyedOnlineRecordRestoresByPosition();
testATrackThatLeftTheProjectCompletesRatherThanStalls(); testATrackThatLeftTheProjectCompletesRatherThanStalls();
testAReentrantInvalidationReachesNothingTheDrainIsUsing();
testAnInvalidationDuringTheLastItemLeavesTheTickUnsettled();
testAnInvalidationDuringAnOnlineDropsThatTicksRelease();
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;
} }