From 02790f1270409897a625256de3849627108fde4c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 6 Aug 2026 22:31:47 -0400 Subject: [PATCH] Gate the FX reclaimer on the transport, both directions; move the release's persist off the drain and onto the project save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drain no longer dirties a project on its own initiative — a spent park record rides the user's next save via OnSaveExtensionConfig. --- src/app/main.cpp | 15 +- src/shell/persist/ext_state_io.cpp | 10 ++ src/shell/persist/session.h | 7 + src/shell/view/fx_reclaim.cpp | 38 +++-- src/shell/view/fx_reclaim.h | 75 +++++++--- tests/test_fx_reclaim.cpp | 222 +++++++++++++++++++++++------ 6 files changed, 288 insertions(+), 79 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 4d9457d..554568b 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -250,18 +250,25 @@ static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*r g_session.requestReload(); } -// Intentional no-ops: ReaSampler stores state via project EXT STATE, not this -// extension's own project lines. The struct is registered ONLY for the -// BeginLoadProjectState undo/redo notification. +// Intentional no-op: ReaSampler stores state via project EXT STATE, not this +// extension's own project lines. static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/, bool /*isUndo*/, project_config_extension_t* /*reg*/) { return false; // we own no project lines — ext state carries our data } -static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/, +// We write no lines into `ctx` — this is the FX reclaimer's one durability point. Its +// spent park records ride the user's save rather than dirtying the project on the +// reclaimer's own initiative (fx_reclaim.h's ReleaseFlush). +// +// A save only: an isUndo pass is REAPER capturing an undo point, not writing the file, +// and clearing the pending flush on one would leave the record unspent on disk for good. +static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool isUndo, project_config_extension_t* /*reg*/) { + if (!isUndo) + reasampler::fxReclaimFlushRelease(g_session); } // Storage must outlive registration — REAPER holds this pointer until we unregister it. diff --git a/src/shell/persist/ext_state_io.cpp b/src/shell/persist/ext_state_io.cpp index 02bdaf3..5852e11 100644 --- a/src/shell/persist/ext_state_io.cpp +++ b/src/shell/persist/ext_state_io.cpp @@ -211,6 +211,16 @@ bool ReaSamplerSession::saveViewStateOnly() { return true; } +// Its twin bar the dirty mark — the one line that differs is the whole point of the +// split, so the two stay spelled out rather than folded behind a flag. +bool ReaSamplerSession::flushViewState() { + ReaProject* proj = activeSavedProject(); + if (!proj) return false; + + writeViewState(proj, view_); + return true; +} + bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { ReaProject* proj = activeSavedProject(); if (!proj) return false; diff --git a/src/shell/persist/session.h b/src/shell/persist/session.h index e8c4706..a7f9522 100644 --- a/src/shell/persist/session.h +++ b/src/shell/persist/session.h @@ -144,6 +144,13 @@ public: // Returns on the SAME rule as saveToActiveProject above, prohibition included. bool saveViewStateOnly(); + // The same write with the dirty mark OMITTED, for a caller already inside REAPER's + // own project save: marking dirty there would leave the project modified the instant + // it was saved, which is the whole reason that caller exists (fx_reclaim's + // ReleaseFlush). Not a general-purpose sibling — a mutation site that skips the dirty + // mark silently loses its write when the user closes without saving. + bool flushViewState(); + // Report-only prune dry-run: feeds the pure core with (present, referenced, // owned) — `present` from the folder enumeration, the other two from the // tracking authority. FAIL-SAFE: tracking state the authority cannot read diff --git a/src/shell/view/fx_reclaim.cpp b/src/shell/view/fx_reclaim.cpp index 091cffd..5cba6bb 100644 --- a/src/shell/view/fx_reclaim.cpp +++ b/src/shell/view/fx_reclaim.cpp @@ -10,8 +10,8 @@ #include #include -#include "shell/persist/session.h" // saveViewStateOnly — the release's durable half -#include "shell/view/view.h" // modeApplyInProgress +#include "shell/persist/session.h" // flushViewState — the release's durable half +#include "shell/view/view.h" // modeApplyInProgress, transportBlocksModeSwitch #include "shell/view/view_park.h" // fxGuidString — the one FX-identity read #include "shell/view/view_tracks.h" @@ -34,6 +34,9 @@ namespace { // FxReclaimQueue, which owns all three. FxReclaimQueue g_queue; +// A release applied to the in-memory model and not yet written — see ReleaseFlush. +ReleaseFlush g_releaseFlush; + // 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 // second queue for the same project and race the outer one's writes track for track. @@ -103,6 +106,11 @@ struct ReclaimHost { .count(); } + // The SAME predicate applyMode refuses a real mode switch on, deliberately shared + // rather than re-read here: what counts as a transport that must not have plugins + // moved under it is one fact with one home (view.h). + bool transportRolling() const { return transportBlocksModeSwitch(project); } + bool liveChain(const std::string& guid, std::vector& out) { MediaTrack* tr = resolveForStep(guid); if (!tr) return false; @@ -136,17 +144,10 @@ struct ReclaimHost { // Reached only after the drain has unwound and the queue has proved its generation // did not move, so the session still holds the model the queue was derived against. - void releaseFx(const std::string& guid) { session->view().releaseSnapshotFx(guid); } - - // saveViewStateOnly writes the ACTIVE project and takes none, so re-read that rather - // than trust the project this tick pinned: a write pumps, and a tab switch inside one - // would otherwise land this model in the wrong project's ext state. Skipping is - // self-healing — coming back to the tab reloads the model from the record still on - // disk, and the re-derived online writes nothing and releases again. Promptless, - // unlike design_view_actions' persistViewState, which can open a modal Save-As. - void persistModel() { - if (EnumProjects(-1, nullptr, 0) != project) return; - session->saveViewStateOnly(); + // The spend lands in memory here and reaches disk on the project's next save. + void releaseFx(const std::string& guid) { + session->view().releaseSnapshotFx(guid); + g_releaseFlush.arm(project); } }; @@ -212,4 +213,15 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { if (!run.aborted && run.settled) g_queue.goIdle(); } +void fxReclaimFlushRelease(ReaSamplerSession& session) { + // The save hook is handed no project, so the active one is the only candidate; a + // mismatch means the release belongs to a tab this save is not writing, and skipping + // leaves the arm standing for that tab's own save. + if (!g_releaseFlush.pendingFor(EnumProjects(-1, nullptr, 0))) return; + // Promptless, unlike design_view_actions' persistViewState, which can open a modal + // Save-As. Disarm only on a write that was ISSUED — a never-saved project writes + // nothing, and dropping the arm there would strand the spend for the whole session. + if (session.flushViewState()) g_releaseFlush.clear(); +} + } // namespace reasampler diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index 55bd25c..6a4e6d7 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -173,18 +173,19 @@ struct ReclaimTickResult { // fx_reclaim.cpp holds the one live implementation. Host must provide: // // double nowMs() monotonic milliseconds +// bool transportRolling() playing or recording — the tick +// may move no plugins at all // bool liveChain(guid, std::vector&) FX identity per live slot; // false when the track is gone // 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; -// called by FxReclaimQueue::run -// after the drain has unwound, -// never from inside the drain -// void persistModel() make the releases just applied -// durable; run calls it ONCE per -// tick and only after a release +// void releaseFx(guid) release the record's FX half and +// arm the save-time flush that +// makes the spend durable; 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 @@ -192,6 +193,18 @@ struct ReclaimTickResult { template ReclaimTickResult drainTick(std::vector& items, ParkClock& clock, Host& host) { ReclaimTickResult result; + + // Mirrors applyMode's refusal (view.h, transportBlocksModeSwitch), and now covers + // every FX write the tool makes, since this loop owns them all. BOTH directions, not + // just the opportunistic offline one: onlining re-instantiates the very plugins the + // user is listening through — the same destruction the switch already refuses — and a + // step is a whole chain with no yield inside it. Nothing is observed on this path, so + // every debounce hold keeps its origin and the first ungated tick fires with no wait. + if (host.transportRolling()) { + result.settled = queueSettled(items); + return result; + } + clock.observe(items, host.nowMs()); const int first = nextEligible(items, clock, host.nowMs()); @@ -358,19 +371,9 @@ public: 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. - if (!tick.released.empty()) { - for (const std::string& guid : tick.released) host.releaseFx(guid); - // The spend has to be as durable as the record it spends. A reload — a - // reopen, or the undo/redo loadFromProject — rebuilds the model from the - // PERSISTED view state, and a record still held there re-derives this same - // Online work and re-drives the chain, silently reverting whatever per-FX - // offline the user set once the reclaim had finished. One persist per tick, - // after every release the tick earned, and still killable in between: die - // there and the record simply survives on disk, whereupon the re-derived - // item finds the chain already online, writes nothing, and releases again. - host.persistModel(); - } + // release fires then. Applied in MEMORY only — the spend reaches disk on the + // project's next save (ReleaseFlush), never on the drain's own initiative. + for (const std::string& guid : tick.released) host.releaseFx(guid); return ReclaimRunResult{tick.writes, tick.settled, false, tick.drops}; } @@ -384,6 +387,30 @@ private: bool pending_ = false; }; +// --- the release's durable half -------------------------------------------------- + +// A park record spent in MEMORY only, waiting for the project's next save. +// +// The drain used to persist it itself, and that dirtied the project: open one saved +// mid-reclaim, let the drain finish, and the user is asked to save a project they only +// opened. The tool no longer marks a project dirty on its own initiative — it writes what +// it owes when the USER saves. The exposure that buys is the window between the two: die +// in there and the record survives on disk unspent, so the re-derived Online item drives +// the chain to it again, reverting any per-FX offline set since the reclaim finished. +// +// Keyed on the project because the save hook is handed none and the session's model is +// whichever project is active: firing for a different one would land this model in +// another project's ext state. A stale arm simply never fires — the same recovery leg. +class ReleaseFlush { +public: + void arm(ReaProject* project) { project_ = project; } + bool pendingFor(ReaProject* active) const { return project_ && project_ == active; } + void clear() { project_ = nullptr; } + +private: + ReaProject* project_ = nullptr; // COMPARE-ONLY, never dereferenced +}; + // --- shell entry points -------------------------------------------------------- // The ONE test the timer makes per tick, mirroring the realtime capture's single @@ -408,8 +435,12 @@ bool fxReclaimDrainInProgress(); // (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. +// park record's FX half — see FxReclaimQueue::run. void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj); +// Writes an in-memory release into the active project's view state. Called from the +// project-save hook and nowhere else: this is the ONE path that makes a spent record +// durable, and it deliberately does not mark the project dirty (see ReleaseFlush). +void fxReclaimFlushRelease(ReaSamplerSession& session); + } // namespace reasampler diff --git a/tests/test_fx_reclaim.cpp b/tests/test_fx_reclaim.cpp index ecd70b2..9a0e2b3 100644 --- a/tests/test_fx_reclaim.cpp +++ b/tests/test_fx_reclaim.cpp @@ -2,10 +2,11 @@ // 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, the release reaching the persisted model, and — -// via a host that invalidates from inside a write — that a re-entered invalidation -// reaches nothing the drain is using. The queue owns the project pin and the timer's -// wakeup for the same reason: a compare-only pointer makes the tab switch assertable. +// offline plugins surviving an online, the transport gate, the release reaching disk on +// the user's save rather than on the drain, and — via a host that invalidates from inside +// a write — that a re-entered invalidation 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" @@ -28,6 +29,11 @@ namespace { // -- a fake project ------------------------------------------------------------ +// Two project handles. The queue and the flush treat a project pointer as COMPARE-ONLY +// and never dereference it, so a fabricated address is a faithful stand-in for REAPER's. +ReaProject* const kProjA = reinterpret_cast(static_cast(0x1)); +ReaProject* const kProjB = reinterpret_cast(static_cast(0x2)); + // One live FX: its identity (empty when REAPER reports none) and whether it is offline. struct FakeFx { std::string guid; @@ -51,7 +57,10 @@ struct FakeHost { int invalidateAfterWrite = -1; int itemsSeenMidDrain = -1; // queue size as the re-entrant caller finds it + bool rolling = false; // the transport gate's input + double nowMs() const { return clock; } + bool transportRolling() const { return rolling; } bool liveChain(const std::string& guid, std::vector& out) { auto it = chains.find(guid); @@ -80,21 +89,18 @@ struct FakeHost { } // `model` is the live host's session model and `persisted` stands in for view_state - // on disk — the blob a reopen or an undo/redo loadFromProject rebuilds from. Set them - // and the release actually lands, so a persist ordered BEFORE it would serialize a - // record that is still held and fail the reload assertions. - ViewModeModel* model = nullptr; + // on disk — the blob a reopen or an undo/redo loadFromProject rebuilds from. The + // drain writes the model and NEVER `persisted`: only saveProject() below does, which + // is what makes "the spend rides the user's save" assertable. + ViewModeModel* model = nullptr; + ReleaseFlush* flush = nullptr; + ReaProject* project = kProjA; std::string persisted; - int persists = 0; void releaseFx(const std::string& guid) { released.insert(guid); if (model) model->releaseSnapshotFx(guid); - } - - void persistModel() { - ++persists; - if (model) persisted = model->serialize(); + if (flush) flush->arm(project); } bool offlineAt(const std::string& guid, std::size_t slot) const { @@ -103,6 +109,14 @@ struct FakeHost { } }; +// What the project-save hook does (fxReclaimFlushRelease): write the in-memory model into +// this project's view state, but only when it is the project holding the release. +void saveProject(FakeHost& host, ReleaseFlush& flush, ReaProject* active) { + if (!flush.pendingFor(active)) return; + host.persisted = host.model->serialize(); + flush.clear(); +} + // -- model helpers ------------------------------------------------------------- TrackSnapshot recordWithFx(std::vector fx) { @@ -178,11 +192,6 @@ FxReclaimQueue queueOf(const ViewModeModel& vm, const std::set& liv return q; } -// Two project handles. The queue treats a project pointer as COMPARE-ONLY and never -// dereferences it, so a fabricated address is a faithful stand-in for REAPER's. -ReaProject* const kProjA = reinterpret_cast(static_cast(0x1)); -ReaProject* const kProjB = reinterpret_cast(static_cast(0x2)); - } // namespace // -- 1. debounce --------------------------------------------------------------- @@ -505,6 +514,8 @@ static void testAnInvalidationDuringAnOnlineDropsThatTicksRelease() { vm.releaseSnapshotFlags("{T}"); FakeHost host; + ReleaseFlush flush; + host.flush = &flush; host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"}); FxReclaimQueue q = queueOf(vm, {"{T}"}); @@ -514,9 +525,9 @@ static void testAnInvalidationDuringAnOnlineDropsThatTicksRelease() { 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 - CHECK(host.persists == 0); // and nothing durable claims it was + CHECK(r.writes == 2); // the item still completed — a step is atomic + CHECK(host.released.empty()); // but its record was NOT spent + CHECK(!flush.pendingFor(kProjA)); // and no save is owed one that never happened // Re-owed, not lost: the re-derived item finds the chain already where the record // wants it, writes nothing more, and releases then. @@ -525,45 +536,97 @@ static void testAnInvalidationDuringAnOnlineDropsThatTicksRelease() { CHECK(q.run(host).settled); CHECK(host.writeLog.size() == 2); CHECK(host.released.count("{T}") == 1); - CHECK(host.persists == 1); + CHECK(flush.pendingFor(kProjA)); } // -- 6. the release has to outlive the session ------------------------------------ -static void testACompletedOnlineIsPersistedSoAReloadPlansNoWork() { +static void testACompletedOnlineReachesDiskOnTheSaveAndNotOnTheDrain() { ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"}); vm.setActiveMode(kDesignModeId); vm.releaseSnapshotFlags("{T}"); - // The bug this closes, stated as the baseline: the model as it stands BEFORE the + // The bug the flush closes, stated as the baseline: the model as it stands BEFORE the // release re-derives the same Online work when reloaded, and re-driving it would // revert whatever per-FX offline the user set after the reclaim had finished. - const std::optional stale = ViewModeModel::deserialize(vm.serialize()); - CHECK(stale.has_value()); - CHECK(derive(*stale, {"{T}"}).size() == 1); - FakeHost host; host.model = &vm; + host.persisted = vm.serialize(); // the project as it sits on disk, record unspent host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"}); + ReleaseFlush flush; + host.flush = &flush; FxReclaimQueue q = queueOf(vm, {"{T}"}); drainToSettled(q, host); CHECK(host.released.count("{T}") == 1); - CHECK(host.persists == 1); // once per tick that released, not once per release - const std::optional reloaded = ViewModeModel::deserialize(host.persisted); - CHECK(reloaded.has_value()); - CHECK(reloaded->snapshot("{T}") == nullptr); // both halves spent ⇒ record erased - CHECK(derive(*reloaded, {"{T}"}).empty()); + // The drain wrote NOTHING durable. That is the whole change: a background tick that + // persisted would also have dirtied the project, asking the user to save one they + // had only opened. + const std::optional beforeSave = ViewModeModel::deserialize(host.persisted); + CHECK(beforeSave.has_value()); + CHECK(beforeSave->snapshot("{T}") != nullptr); + CHECK(derive(*beforeSave, {"{T}"}).size() == 1); // still stale on disk + CHECK(flush.pendingFor(kProjA)); // ...and the save knows it is owed + + // The user's next save is what spends it — and afterwards a reload plans no work, + // so nothing re-drives the chain. + saveProject(host, flush, kProjA); + CHECK(!flush.pendingFor(kProjA)); + const std::optional afterSave = ViewModeModel::deserialize(host.persisted); + CHECK(afterSave.has_value()); + CHECK(afterSave->snapshot("{T}") == nullptr); // both halves spent ⇒ record erased + CHECK(derive(*afterSave, {"{T}"}).empty()); } -static void testATickThatSpendsNoRecordPersistsNothing() { - // OFFLINE work spends no record, so there is nothing to make durable — and a - // background reclaim must not dirty the project for work it can simply redo. +static void testAReleaseKilledBeforeTheFlushIsSimplyReEarned() { + // The exposure the save-time flush opens: between the in-memory release and the next + // save, the record on disk is still unspent. Quitting there must cost a redo of the + // reclaim, never a repair. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"}); + vm.setActiveMode(kDesignModeId); + vm.releaseSnapshotFlags("{T}"); + + FakeHost host; + host.model = &vm; + host.persisted = vm.serialize(); + host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"}); + ReleaseFlush flush; + host.flush = &flush; + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + drainToSettled(q, host); + CHECK(host.writeLog.size() == 2); // the chain really was driven online + CHECK(flush.pendingFor(kProjA)); + + // Killed here: no save, so the model in memory goes and the disk record survives. + const std::optional reopened = ViewModeModel::deserialize(host.persisted); + CHECK(reopened.has_value()); + ViewModeModel reloaded = *reopened; + + FakeHost after; + after.chains = host.chains; // the DAW state outlives the reload: the chain is online + after.model = &reloaded; + ReleaseFlush flushAfter; + after.flush = &flushAfter; + + FxReclaimQueue q2 = queueOf(reloaded, {"{T}"}); + CHECK(q2.items().size() == 1); // the surviving record re-derives the same Online work + CHECK(drainToSettled(q2, after) == 1); + CHECK(after.writeLog.empty()); // compare-before-write: nothing left to move + CHECK(after.released.count("{T}") == 1); + CHECK(flushAfter.pendingFor(kProjA)); // and the spend is owed to the next save again +} + +static void testATickThatSpendsNoRecordOwesNoSaveAnything() { + // OFFLINE work spends no record, so there is nothing to make durable — and the + // reclaimer must never give the user's next save work it did not earn. ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); FakeHost host; host.model = &vm; host.chains["{T}"] = onlineChain({"{FX-A}"}); + ReleaseFlush flush; + host.flush = &flush; FxReclaimQueue q = queueOf(vm, {"{T}"}); q.run(host); // observes the hold at t=0 @@ -572,7 +635,8 @@ static void testATickThatSpendsNoRecordPersistsNothing() { CHECK(host.writeLog.size() == 1); // it did move the plugin CHECK(host.released.empty()); - CHECK(host.persists == 0); + CHECK(!flush.pendingFor(kProjA)); + saveProject(host, flush, kProjA); CHECK(host.persisted.empty()); } @@ -772,6 +836,79 @@ static void testATickThatLostNothingNeitherPrintsNorDisturbsTheDropMemo() { CHECK(!shouldReportDrops(/*sameOwnerAsLast=*/true, lost, lost)); } +// -- 9. the transport gate ------------------------------------------------------------ + +static void testARollingTransportMovesNoPluginsAndKeepsTheHold() { + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}"}); + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + CHECK(!q.run(host).settled); // the hold starts at t=0 + + host.rolling = true; + host.clock += kParkHoldMs; // matured — the debounce is no longer what holds it back + const ReclaimRunResult gated = q.run(host); + CHECK(gated.writes == 0); + CHECK(host.writeLog.empty()); + CHECK(!gated.aborted); + CHECK(!gated.settled); // the work is still owed, so the shell must not go idle + CHECK(!q.stale()); // and the queue it will resume from survives untouched + + // The hold kept its origin across the gated tick: the drain fires on the FIRST tick + // after the transport stops, with no fresh 3s wait. + host.rolling = false; + CHECK(q.run(host).settled); + CHECK(host.writeLog.size() == 2); +} + +static void testTheGateStopsTheOnlineDirectionToo() { + // The ruling is symmetric because both directions load or unload plugins: onlining + // re-instantiates the very chain the user is listening through. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"}); + vm.setActiveMode(kDesignModeId); + vm.releaseSnapshotFlags("{T}"); + + FakeHost host; + host.model = &vm; + host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"}); + ReleaseFlush flush; + host.flush = &flush; + host.rolling = true; + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Online); + const ReclaimRunResult gated = q.run(host); + CHECK(gated.writes == 0); + CHECK(!gated.settled); + CHECK(host.released.empty()); // no record spent while the transport rolls + CHECK(!flush.pendingFor(kProjA)); + CHECK(host.offlineAt("{T}", 0)); // the chain is untouched, still parked + + // The contrast, so the assertions above are about the gate and not about an online + // queue that cannot drain: stop the transport and the same tick completes. + host.rolling = false; + CHECK(q.run(host).settled); + CHECK(host.writeLog.size() == 2); + CHECK(host.released.count("{T}") == 1); +} + +static void testAGatedTickStillGoesIdleWithNothingLeftToConverge() { + // A settled queue must not spin the timer for the length of a playback pass: the + // gate's answer is "no writes", not "never finished". + ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}"}); + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + q.run(host); + host.clock += kParkHoldMs; + CHECK(q.run(host).settled); + + host.rolling = true; + CHECK(q.run(host).settled); +} + int main() { testFastFlipMovesZeroPluginsButAHeldParkStillReclaims(); @@ -790,8 +927,9 @@ int main() { testAnInvalidationDuringTheLastItemLeavesTheTickUnsettled(); testAnInvalidationDuringAnOnlineDropsThatTicksRelease(); - testACompletedOnlineIsPersistedSoAReloadPlansNoWork(); - testATickThatSpendsNoRecordPersistsNothing(); + testACompletedOnlineReachesDiskOnTheSaveAndNotOnTheDrain(); + testAReleaseKilledBeforeTheFlushIsSimplyReEarned(); + testATickThatSpendsNoRecordOwesNoSaveAnything(); testADropIsReportedOnceAcrossRepeatedRederivations(); testAnOfflineTickReportsNoDrops(); @@ -801,6 +939,10 @@ int main() { testOnlyANoProjectTickClearsTheWakeup(); testATickThatLostNothingNeitherPrintsNorDisturbsTheDropMemo(); + testARollingTransportMovesNoPluginsAndKeepsTheHold(); + testTheGateStopsTheOnlineDirectionToo(); + testAGatedTickStillGoesIdleWithNothingLeftToConverge(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }