From 5dcfd3a53f9af8da0b00e22a87bfdd345d1501c0 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 6 Aug 2026 21:08:34 -0400 Subject: [PATCH] Reclaim drain owns its work: invalidation only bumps a generation, and a generation that moved aborts the tick's commit and its release. --- src/app/main.cpp | 16 +- src/shell/actions/design_view_actions.cpp | 5 +- src/shell/view/fx_reclaim.cpp | 34 ++-- src/shell/view/fx_reclaim.h | 124 +++++++++++++-- src/shell/view/view.cpp | 28 +++- src/shell/view/view.h | 3 + tests/test_fx_reclaim.cpp | 179 ++++++++++++++++++---- 7 files changed, 321 insertions(+), 68 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index e70606f..4817593 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -189,7 +189,10 @@ static void OnTimer() // 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 // 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 // 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 // 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). - // modeApplyInProgress is tested BEFORE the consume, not after the applyMode call: - // the load signal is one-shot, a re-entered applyMode fails closed, and a reapply - // dropped here is the saved mode never applied at all. Holding the whole block - // back one tick also keeps the panel re-arm ahead of the reapply. - if (!reasampler::modeApplyInProgress() && g_session.consumeLoadSignal()) { + // Both latches are tested BEFORE the consume, not after the applyMode call: the load + // signal is one-shot, a re-entered applyMode fails closed, and a reapply dropped here + // is the saved mode never applied at all. Holding the whole block back one tick also + // keeps the panel re-arm ahead of the reapply. + if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress() && + g_session.consumeLoadSignal()) { reasampler::bankPanelNotifyProjectLoaded(); // Reconcile lane ownership against the live project's lanes (P_LANENAME, // the cross-session source of truth) BEFORE reapplying visibility. Never diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp index 8f7bdba..e83e5e1 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -104,7 +104,10 @@ std::vector selectedTrackGuids() { } // 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() { applyMode(g_session->view(), g_session->view().activeModeId(), nullptr); } diff --git a/src/shell/view/fx_reclaim.cpp b/src/shell/view/fx_reclaim.cpp index 3360ae3..27c1b55 100644 --- a/src/shell/view/fx_reclaim.cpp +++ b/src/shell/view/fx_reclaim.cpp @@ -32,14 +32,12 @@ namespace { // 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. -std::vector g_items; -ReaProject* g_project = nullptr; -ParkClock g_clock; +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 walk the -// same queue the outer one holds a reference into, re-running the very item that outer -// step is still writing. +// the timer and land back here mid-drain. Fail closed: a re-entered drain would derive a +// second queue for the same project and race the outer one's writes track for track. bool g_draining = false; struct DrainLatch { DrainLatch() { g_draining = true; } @@ -112,13 +110,15 @@ struct ReclaimHost { 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); } }; // 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_items.clear(); + g_queue.discard(); g_project = nullptr; g_fxReclaimPending = false; } @@ -128,10 +128,15 @@ void goIdle() { bool g_fxReclaimPending = false; 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; } +bool fxReclaimDrainInProgress() { return g_draining; } + void fxReclaimTick(ViewModeModel& model, ReaProject* proj) { if (g_draining) return; // 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); if (!ValidatePtr2(nullptr, project, "ReaProject*") || (g_project && g_project != project)) { goIdle(); - g_clock.forget(); + g_queue.forgetHolds(); return; } // 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. - if (!g_project) { + // invalidated, so a REAPER quit mid-drain simply re-derives and finishes on reopen. + if (g_queue.stale()) { g_project = project; - g_items = makeQueue( - view::desiredFxState(model, model.activeModeId(), liveTrackGuids(project))); + g_queue.adopt(makeQueue( + view::desiredFxState(model, model.activeModeId(), liveTrackGuids(project)))); } 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 diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index 2e5cfa3..a1db774 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -92,6 +92,9 @@ public: 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: std::map since_; }; @@ -119,6 +122,12 @@ inline int nextEligible(const std::vector& items, const ParkClock& struct ReclaimTickResult { int writes = 0; 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 released; }; // 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 // own discarded undo block; // 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 ReclaimTickResult drainTick(std::vector& items, ParkClock& clock, Host& host) { ReclaimTickResult result; @@ -166,11 +182,12 @@ ReclaimTickResult drainTick(std::vector& items, ParkClock& clock, H for (const FxOfflineWrite& w : resolved.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 - // been driven to every state the record captured, so the record is spent. - // Releasing it any earlier destroys the only account of which plugins the - // USER had offline before the park, and the next online blanket-enables them. - if (item.work.kind == view::FxWorkKind::Online) host.releaseFx(item.work.trackGuid); + // The record is spent only once the chain has been driven to every state it + // captured — releasing any earlier destroys the only account of which plugins + // the USER had offline before the park, and the next online blanket-enables + // them. Reported rather than applied; see ReclaimTickResult::released. + 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 @@ -181,23 +198,104 @@ ReclaimTickResult drainTick(std::vector& items, ParkClock& clock, H 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 items) { + items_ = std::move(items); + derived_ = true; + derivedAt_ = generation_; + } + + void discard() { + items_.clear(); + derived_ = false; + } + + void forgetHolds() { clock_.forget(); } + + const std::vector& items() const { return items_; } + + template + ReclaimRunResult run(Host& host) { + const unsigned long long entered = generation_; + + std::vector 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 items_; + ParkClock clock_; + unsigned long long generation_ = 0; + unsigned long long derivedAt_ = 0; + bool derived_ = 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; -// Discards the derived queue. Called from every path that can change what the FX chains -// SHOULD be — which is applyMode's success path and nothing else, because every -// membership, snapshot and active-mode mutation reapplies through it. The debounce clock -// deliberately survives: it is keyed on the track, not on the queue. +// 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 +// track, not on the queue. 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 // `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 -// pump the message loop), or when the active project is not the one the queue was -// derived against. +// applyMode is on the stack, while already draining, or when the active project is not +// the one the queue was derived against. void fxReclaimTick(ViewModeModel& model, ReaProject* proj); } // namespace reasampler diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index 42c9b66..685f48a 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -62,7 +62,9 @@ bool transportBlocksModeSwitch(ReaProject* proj) { 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 ApplyLatch latch; @@ -203,12 +205,26 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject // an undo rolling the model back underneath it. 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; } +} // 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 diff --git a/src/shell/view/view.h b/src/shell/view/view.h index 0efb4b5..d91e5c4 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -59,6 +59,9 @@ bool modeApplyInProgress(); // (modeApplyInProgress), or if the project does not validate. A reapply (target == // active) is never gated and never touches solo. `proj` == nullptr means the current // 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); } // namespace reasampler diff --git a/tests/test_fx_reclaim.cpp b/tests/test_fx_reclaim.cpp index 6577d02..9e9d040 100644 --- a/tests/test_fx_reclaim.cpp +++ b/tests/test_fx_reclaim.cpp @@ -1,8 +1,9 @@ // 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 -// run in the background are assertable here: the debounce, the per-tick budget's -// between-track yield, the queue being a cache of intent that a model change discards, -// and the user's own offline plugins surviving an online. +// loop is templated on its host precisely so the properties that make it safe to run in +// the background are assertable here: the debounce, the per-tick budget's between-track +// yield, the queue being a cache of intent that a model change discards, the user's own +// 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" @@ -39,6 +40,13 @@ struct FakeHost { double clock = 0.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; } bool liveChain(const std::string& guid, std::vector& out) { @@ -58,6 +66,12 @@ struct FakeHost { it->second[static_cast(fxIndex)].offline = offline; clock += stepCostMs; writeLog.push_back(guid + ":" + std::to_string(fxIndex) + (offline ? "=off" : "=on")); + + if (reentrant && static_cast(writeLog.size()) == invalidateAfterWrite) { + itemsSeenMidDrain = static_cast(reentrant->items().size()); + reentrant->discard(); + reentrant->invalidate(); + } return true; } @@ -121,15 +135,29 @@ void matureHold(std::vector& items, ParkClock& clock, FakeHost& hos } // 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& items, ParkClock& clock, FakeHost& host, int maxTicks = 64) { for (int t = 1; t <= maxTicks; ++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 return -1; } +FxReclaimQueue queueOf(const ViewModeModel& vm, const std::set& live) { + FxReclaimQueue q; + q.adopt(derive(vm, live)); + return q; +} + } // namespace // -- 1. debounce --------------------------------------------------------------- @@ -139,21 +167,20 @@ static void testFastFlipMovesZeroPluginsButAHeldParkStillReclaims() { ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"}); FakeHost host; host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); - ParkClock clock; - std::vector items = derive(vm, {"{T}"}); - CHECK(items.size() == 1 && items[0].work.kind == FxWorkKind::Offline); - CHECK(drainTick(items, clock, host).writes == 0); - CHECK(!drainTick(items, clock, host).settled); // waiting out its hold, not finished + FxReclaimQueue q = queueOf(vm, {"{T}"}); + CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Offline); + CHECK(q.run(host).writes == 0); + 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 // reclaimer is now owed an ONLINE — over a chain it never got round to offlining. host.clock = 500.0; vm.setActiveMode(kDesignModeId); vm.releaseSnapshotFlags("{T}"); - items = derive(vm, {"{T}"}); - CHECK(items.size() == 1 && items[0].work.kind == FxWorkKind::Online); - drainToSettled(items, clock, host); + q.adopt(derive(vm, {"{T}"})); + CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Online); + drainToSettled(q, host); // The whole point of the 3s hold: a fast A→B→A flip moved NO plugins. CHECK(host.writeLog.empty()); @@ -165,11 +192,10 @@ static void testFastFlipMovesZeroPluginsButAHeldParkStillReclaims() { ViewModeModel held = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"}); FakeHost host2; host2.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); - ParkClock clock2; - std::vector items2 = derive(held, {"{T}"}); - CHECK(drainTick(items2, clock2, host2).writes == 0); // observed parked at t=0 + FxReclaimQueue q2 = queueOf(held, {"{T}"}); + CHECK(q2.run(host2).writes == 0); // observed parked at t=0 host2.clock = kParkHoldMs; - drainToSettled(items2, clock2, host2); + drainToSettled(q2, host2); CHECK(host2.writeLog.size() == 3); 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 @@ -329,10 +355,9 @@ static void testAPluginTheUserOfflinedIsNeverOnlinedByTheDrain() { FakeHost host; host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); // fully parked - ParkClock clock; - std::vector items = derive(vm, {"{T}"}); - drainToSettled(items, clock, host); + FxReclaimQueue q = queueOf(vm, {"{T}"}); + drainToSettled(q, host); CHECK(!host.offlineAt("{T}", 0)); 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 // the end, because the budget cannot split one track's move. host.stepCostMs = kOnlineBudgetMs + 10.0; - ParkClock clock; - std::vector items = derive(vm, {"{T}"}); - CHECK(items.size() == 1); - CHECK(drainToSettled(items, clock, host) == 1); + FxReclaimQueue q = queueOf(vm, {"{T}"}); + CHECK(q.items().size() == 1); + CHECK(drainToSettled(q, host) == 1); const std::vector expected{"{T}:0=on", "{T}:2=on"}; CHECK(host.writeLog == expected); // slot 1 stays offline, as the record captured CHECK(host.offlineAt("{T}", 1)); @@ -373,15 +397,110 @@ static void testSlotKeyedOnlineRecordRestoresByPosition() { static void testATrackThatLeftTheProjectCompletesRatherThanStalls() { ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); FakeHost host; // no chain registered ⇒ liveChain reports the track gone - ParkClock clock; - std::vector items = derive(vm, {"{T}"}); - matureHold(items, clock, host); - CHECK(drainTick(items, clock, host).settled); + FxReclaimQueue q = queueOf(vm, {"{T}"}); + CHECK(!q.run(host).settled); // observes the hold at t=0; not matured yet + host.clock += kParkHoldMs; + CHECK(q.run(host).settled); CHECK(host.writeLog.empty()); 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 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() { testFastFlipMovesZeroPluginsButAHeldParkStillReclaims(); @@ -396,6 +515,10 @@ int main() { testSlotKeyedOnlineRecordRestoresByPosition(); testATrackThatLeftTheProjectCompletesRatherThanStalls(); + testAReentrantInvalidationReachesNothingTheDrainIsUsing(); + testAnInvalidationDuringTheLastItemLeavesTheTickUnsettled(); + testAnInvalidationDuringAnOnlineDropsThatTicksRelease(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }