From 9b6f725747edca2c38560600e5f7ce5e591b281e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 7 Aug 2026 13:45:58 -0400 Subject: [PATCH] Close the FX reclaimer's undo span at the state-load hook, and expose the drain's direction A Ctrl-Z mid-span used to close the pre-undo block over post-undo state; it now terminates at BeginLoadProjectState and stops the drain where it stands. fxReclaimActivity reports Online/Offline/Idle; fxReclaimPending is unchanged. --- src/app/main.cpp | 8 ++ src/shell/view/CLAUDE.md | 32 ++++-- src/shell/view/fx_reclaim.cpp | 48 +++++++-- src/shell/view/fx_reclaim.h | 124 +++++++++++++++++++---- tests/test_fx_reclaim.cpp | 179 ++++++++++++++++++++++++++++++++++ 5 files changed, 357 insertions(+), 34 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 2d7e60e..567c02c 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -247,7 +247,15 @@ static void OnTimer() static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/) { if (isUndo) + { + // BEFORE the reload request, and the whole reason this is a hook rather than + // something the reload's own reapply does on arrival: the reclaimer's undo block + // is open for at least kParkHoldMs after every real mode switch, and a switch is + // exactly what this undo is rolling back. Closing it here is the last moment it + // still closes over the state it was opened on. + reasampler::fxReclaimAbandonForStateLoad(); g_session.requestReload(); + } } // Intentional no-op: ReaSampler stores state via project EXT STATE, not this diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index 571d5d3..c6bb253 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -84,12 +84,25 @@ decide membership or mode rules. block swallows every edit the user makes afterwards, indefinitely, so every terminating path routes through ONE idempotent close (`fxReclaimEndUndoSpan`): the drain settling, aborting on a generation bump, or deferring on the transport; the - project retargeting or closing; the next apply superseding it; a project save; - extension unload. The span may stay open only across a wait it can BOUND — the 3s - debounce, which is the dead time between the switch and its first FX write, and - the only reason the span exists. A TRANSPORT deferral is not bounded (it lasts as - long as the playback pass), so it ends the span and the parking that follows mints - its own point (`spanEndsAfter`). + project retargeting or closing; the next apply superseding it; a USER-DRIVEN STATE + LOAD; a project save; extension unload. The span may stay open only across a wait it + can BOUND — the 3s debounce, which is the dead time between the switch and its first + FX write, and the only reason the span exists. A TRANSPORT deferral is not bounded + (it lasts as long as the playback pass), so it ends the span and the parking that + follows mints its own point (`spanEndsAfter`). +- **A Ctrl-Z terminates the span itself, at the hook, before REAPER restores + anything.** `fxReclaimAbandonForStateLoad` runs from `BeginLoadProjectState` + (`main.cpp`) — the only point at which the block still closes over the state it was + OPENED on. The span is open for at least `kParkHoldMs` after every real switch and a + switch is exactly what a user undoes, so letting the close happen where it used to — + as a side effect of the reapply the reloaded model triggers, a full timer tick later + — closed the pre-undo block over POST-undo state, minting a spurious point and + destroying the redo branch. The same call stops any drain on the stack where it + stands (`FxReclaimQueue::abandon`, refused at both host entry points), which is + stronger than an invalidation: an invalidation still finishes the track it is on, + because a step is atomic. Daniel's ruling, and the accepted cost: undoing a switch + mid-drain leaves a PARTIAL park — some FX offlined, some not — covered by the one + point the terminator closed. - **A reapply that wrote nothing leaves no undo point** — and a reapply never holds the span open, so a project open, a membership edit and `render_in_place`'s inner reapply all keep the block shape they had. The load-tick reapply @@ -148,7 +161,7 @@ applies the resulting lane state to live tracks. - `view_reconcile` — the repair pass for a project state REAPER reloaded under us: `noteAppliedState` records what an apply left driven; `reconcileAppliedFlags` diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for `UNDO_STATE_TRACKCFG` not carrying `B_SHOWINTCP`/`B_SHOWINMIXER` (DAW-confirmed). - `view_tracks` — the scaffolding every write pass sets up: `TrackHandles`/`TrackByGuid`, `readFolderEntries`/`indexHandles`, and `UiRefreshHold` (the `PreventUIRefresh` RAII pair every write phase runs under). - `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`. -- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). `UndoSpan` owns the ONE undo block the whole drain writes under — opened by `applyMode` on a real switch, else lazily by the first `writeOffline`, and closed through the single idempotent `fxReclaimEndUndoSpan` that every terminating path routes through (`spanEndsAfter` decides which ticks terminate). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. +- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). `UndoSpan` owns the ONE undo block the whole drain writes under — opened by `applyMode` on a real switch, else lazily by the first `writeOffline`, and closed through the single idempotent `fxReclaimEndUndoSpan` that every terminating path routes through (`spanEndsAfter` decides which ticks terminate; `fxReclaimAbandonForStateLoad` is the undo/redo terminator, which also stops the drain). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. `fxReclaimActivity` reports WHICH DIRECTION the drain is moving for an indicator to paint — Idle whenever nothing may move, so a debounced or transport-held offline item never names a direction nothing is travelling in; undirected `fxReclaimPending` stays the timer's one idle test and must not drive a UI (it is true throughout the debounce, when only the OPPOSITE direction is eligible). ## Gotchas @@ -182,8 +195,9 @@ applies the resulting lane state to live tracks. flip — still closes on `kViewUndoMask` alone and costs no FX serialization. What a Ctrl-Z cannot restore is anything the reclaimer converges AFTER the span closed: a transport deferral, and a drain no switch opened a span for, each mint their own - point. Whether that reads as correct in the DAW is `[verify — DAW]`, - `docs/VERIFICATION.md` §"Mode switching". + point — and, since the terminator above, whatever the drain had NOT yet written when + the undo arrived, because the span closes at that instant. Whether that reads as + correct in the DAW is `[verify — DAW]`, `docs/VERIFICATION.md` §"Mode switching". - **The `view_state` write is NOT inside `applyMode`'s block — but on a REAL switch it is now inside the SPAN.** `persistViewState` (`design_view_actions.cpp`) runs after `applyMode` has returned, and a real switch returns with the span open, so diff --git a/src/shell/view/fx_reclaim.cpp b/src/shell/view/fx_reclaim.cpp index f5c60ee..7d1c4ce 100644 --- a/src/shell/view/fx_reclaim.cpp +++ b/src/shell/view/fx_reclaim.cpp @@ -60,6 +60,15 @@ void closeSpan() { if (ValidatePtr2(nullptr, proj, "ReaProject*")) Undo_EndBlock2(proj, label.c_str(), mask); } +// Monotonic and sub-millisecond: the drain's budget discriminates at 30ms, which +// GetTickCount's ~16ms granularity cannot do. No wraparound to guard against, so +// util::elapsedAtLeast (whose whole reason for existing is that wrap) does not apply. +double monotonicMs() { + return std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + // 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. @@ -122,21 +131,18 @@ struct ReclaimHost { return cachedTrack_; } - // Monotonic and sub-millisecond: the budget below discriminates at 30ms, which - // GetTickCount's ~16ms granularity cannot do. No wraparound to guard against, so - // util::elapsedAtLeast (whose whole reason for existing is that wrap) does not apply. - double nowMs() const { - return std::chrono::duration( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - } + double nowMs() const { return monotonicMs(); } // The SAME predicate applyMode refuses a real mode switch on, deliberately shared // rather than re-read here: "the transport is rolling" is one fact with one home // (view.h). drainTick narrows what it gates with this answer to offline work only. bool transportRolling() const { return transportBlocksModeSwitch(project); } + // Refusing here ends the drain's REMAINING tracks after an abandon: drainTick treats a + // false as "this track is gone", so the rest of the queue costs one bool apiece and + // reaches no REAPER call at all. bool liveChain(const std::string& guid, std::vector& out) { + if (g_queue.abandoned()) return false; MediaTrack* tr = resolveForStep(guid); if (!tr) return false; const int count = TrackFX_GetCount(tr); @@ -148,7 +154,11 @@ struct ReclaimHost { return true; } + // ...and here for the track being written RIGHT NOW, whose own pump is how the state + // load arrived. FIRST, before the openSpan below: a write past the terminator would + // both land in the project the load is replacing and reopen the span it just closed. bool writeOffline(const std::string& guid, int fxIndex, bool offline) { + if (g_queue.abandoned()) return false; MediaTrack* tr = resolveForStep(guid); if (!tr) return false; if (fxIndex < 0 || fxIndex >= TrackFX_GetCount(tr)) return false; // chain shrank @@ -199,6 +209,20 @@ void reportDrops(ReaProject* proj, const std::vector& drops) { bool fxReclaimPending() { return g_queue.pending(); } +FxReclaimActivity fxReclaimActivity() { + // The QUEUE's own pin, never the active tab: the direction is a fact about the project + // the drain is converging, and reading another tab's transport into it would gate the + // answer on a playhead that has nothing to do with this work. No pin means idle — and + // costs the caller no REAPER call at all, which is what makes this safe to poll per + // frame. + ReaProject* const proj = g_queue.project(); + if (!proj) return FxReclaimActivity::Idle; + // The pin is only re-tested at beginTick; a poll landing between two ticks can find the + // project already gone, and GetPlayStateEx is about to be handed this pointer. + if (!ValidatePtr2(nullptr, proj, "ReaProject*")) return FxReclaimActivity::Idle; + return g_queue.activity(monotonicMs(), transportBlocksModeSwitch(proj)); +} + 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 @@ -256,6 +280,14 @@ void fxReclaimLabelUndoSpan(const char* label, int mask) { void fxReclaimEndUndoSpan() { closeSpan(); } +void fxReclaimAbandonForStateLoad() { + // Close FIRST and unconditionally. The abandon below only stops future writes; the + // block is what a Ctrl-Z would otherwise close over the wrong state, and it is open + // whether or not a drain is on the stack at all. + closeSpan(); + g_queue.abandon(); +} + void fxReclaimFlushRelease(ReaSamplerSession& session) { // GetCurrentProjectInLoadSave() names the project THIS hook is being asked to save // (reaper_plugin_functions.h:1582-1587) -- not merely the active tab, which a diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index e49d618..9d402a1 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -122,6 +122,56 @@ inline int nextEligible(const std::vector& items, const ParkClock& return -1; } +// The item a step may move RIGHT NOW, under BOTH gates the loop applies: the debounce +// above, and the transport, which mirrors applyMode's refusal of a real mode switch +// (view.h, transportBlocksModeSwitch) narrowed to the OFFLINE direction only — +// onlining is the switch's own unfinished promise and the user is waiting on it, while +// offlining is opportunistic reclaim that can always wait for the transport to stop. +// -1 when nothing may move; `transportDeferred` is raised only when the transport, and +// not the debounce, is what held an otherwise-eligible item back. +// +// The ONE home of that pair: drainTick steps through it and nextActivity reports +// through it, so the direction an indicator paints can never disagree with the +// direction the next step actually takes. +inline int nextMovable(const std::vector& items, const ParkClock& clock, + double nowMs, bool transportRolling, bool& transportDeferred) { + const int i = nextEligible(items, clock, nowMs); + if (i < 0) return -1; + if (transportRolling && + items[static_cast(i)].work.kind == view::FxWorkKind::Offline) { + transportDeferred = true; + return -1; + } + return i; +} + +// Which way the drain is MOVING — the panel's indicator, and undirected `fxReclaimPending` +// is what it may not be driven off: for the whole ~3s after a switch the queue holds the +// park's offline work while the only thing eligible is the RETURNING chains' online work, +// so "pending" there names the opposite of what is happening. +// +// Idle means nothing may move on this tick, and deliberately does not distinguish why — +// an empty queue, an offline item inside its hold, and an offline item held by a rolling +// transport all read Idle, because none of them is work the user is currently waiting +// through. Online while the transport rolls is a real state and reads Online: that +// direction is not gated. +enum class FxReclaimActivity { + Idle, + Online, + Offline, +}; + +inline FxReclaimActivity nextActivity(const std::vector& items, + const ParkClock& clock, double nowMs, + bool transportRolling) { + bool deferred = false; + const int i = nextMovable(items, clock, nowMs, transportRolling, deferred); + if (i < 0) return FxReclaimActivity::Idle; + return items[static_cast(i)].work.kind == view::FxWorkKind::Online + ? FxReclaimActivity::Online + : FxReclaimActivity::Offline; +} + // --- the drain ---------------------------------------------------------------- // One ONLINE item's captured state the chain had no home for. An OFFLINE item can @@ -211,21 +261,13 @@ ReclaimTickResult drainTick(std::vector& items, ParkClock& clock, H // its track genuinely stops owing offline work, matured or not. clock.observe(items, host.nowMs()); - // Mirrors applyMode's refusal (view.h, transportBlocksModeSwitch), narrowed to the - // OFFLINE direction only: onlining is the mode switch's own unfinished promise — the - // user is waiting on it — while offlining is opportunistic reclaim that can always - // wait for the transport to stop. Onlining while rolling still costs a plugin load - // mid-playback; that is an accepted trade for this gate's shape, not a hazard it - // introduces. - const bool offlineAllowed = !host.transportRolling(); + // Read once for the whole tick and applied through nextMovable, which owns the gate + // (and the reasoning for narrowing it to Offline). Onlining while rolling still costs + // a plugin load mid-playback; that is an accepted trade for this gate's shape, not a + // hazard it introduces. + const bool rolling = host.transportRolling(); auto nextAllowed = [&](double nowMs) { - const int i = nextEligible(items, clock, nowMs); - if (i >= 0 && !offlineAllowed && - items[static_cast(i)].work.kind == view::FxWorkKind::Offline) { - result.transportDeferred = true; - return -1; - } - return i; + return nextMovable(items, clock, nowMs, rolling, result.transportDeferred); }; const int first = nextAllowed(host.nowMs()); @@ -318,6 +360,21 @@ public: pending_ = true; } + // A project-state load the USER drove (undo/redo). The whole project is about to be + // replaced, so this is STRONGER than an invalidation: the queue's work is discarded as + // usual, AND any drain on the stack — reachable only through a write's own message + // pump — stops where it stands instead of finishing the track it is on. Accepted, and + // the reason a step's atomicity yields here: a park half-landed this way stays + // half-landed, covered by the one point the terminator closed. + void abandon() { + invalidate(); + abandoned_ = true; + } + + // Read by the shell's host on every step; false again from the next run(), because the + // stop belongs to the tick that was interrupted, not to the project that replaced it. + bool abandoned() const { return abandoned_; } + // The ONE test the timer makes per tick. False means settled: nothing to converge // until the next invalidation. bool pending() const { return pending_; } @@ -374,16 +431,29 @@ public: const std::vector& items() const { return items_; } + // Which way the drain is moving, for an indicator to paint — see nextActivity, which + // owns the rule. Const and cheap: the panel polls it per frame. + FxReclaimActivity activity(double nowMs, bool transportRolling) const { + // The queue's own storage is EMPTY for a drain's whole duration (run() moves it + // into locals), and a write pumps the message loop — so a repaint landing inside + // one would otherwise read the drain as idle at exactly the moment it is working. + if (draining_ != FxReclaimActivity::Idle) return draining_; + return nextActivity(items_, clock_, nowMs, transportRolling); + } + template ReclaimRunResult run(Host& host) { const unsigned long long entered = generation_; + abandoned_ = false; std::vector work; ParkClock clock; work.swap(items_); clock.swap(clock_); + draining_ = nextActivity(work, clock, host.nowMs(), host.transportRolling()); const ReclaimTickResult tick = drainTick(work, clock, host); + draining_ = FxReclaimActivity::Idle; // 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. @@ -420,6 +490,8 @@ private: unsigned long long derivedAt_ = 0; bool derived_ = false; bool pending_ = false; + bool abandoned_ = false; + FxReclaimActivity draining_ = FxReclaimActivity::Idle; }; // --- the release's durable half -------------------------------------------------- @@ -544,6 +616,13 @@ inline bool spanEndsAfter(const ReclaimRunResult& run) { // pointer test. False means the queue is settled and there is nothing to converge. bool fxReclaimPending(); +// Which direction the drain is moving, answered against the project the queue was derived +// against rather than the active tab. `fxReclaimPending` above is deliberately left +// undirected and unchanged; this is the surface an indicator must be driven off instead, +// because "pending" is true throughout the debounce, when the only thing moving is the +// OPPOSITE direction. Idle whenever nothing may move — see FxReclaimActivity. +FxReclaimActivity fxReclaimActivity(); + // 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 @@ -578,11 +657,22 @@ void fxReclaimLabelUndoSpan(const char* label, int mask); // The ONE close, idempotent: every terminating path routes through it — the drain // settling, aborting, or deferring on the transport; the project retargeting or closing; -// the next apply superseding it; a project save; extension unload. A leaked open block -// would swallow everything the user does afterwards, so nothing may return past a -// terminating condition without calling this. +// the next apply superseding it; a user-driven state load; a project save; extension +// unload. A leaked open block would swallow everything the user does afterwards, so +// nothing may return past a terminating condition without calling this. void fxReclaimEndUndoSpan(); +// A project-state load the USER drove — Ctrl-Z or Ctrl-Shift-Z. Ends the span and stops +// any drain on the stack (FxReclaimQueue::abandon). +// +// Called from the projectconfig BeginLoadProjectState hook, which fires BEFORE REAPER +// restores anything: that is the ONLY point at which the block can still close over the +// state it was opened on. The span is open for at least kParkHoldMs after every real +// switch and a switch is exactly what a user undoes, so closing it as a side effect of +// the reapply the load triggers — several calls later, over the restored project — would +// mint the point over post-undo state and destroy the redo branch. +void fxReclaimAbandonForStateLoad(); + // Writes an in-memory release into the SAVING project's view state (identified via // GetCurrentProjectInLoadSave(), not the active tab). Called from the project-save // hook and nowhere else: this is the ONE path that makes a spent record durable, and diff --git a/tests/test_fx_reclaim.cpp b/tests/test_fx_reclaim.cpp index 77c3551..6987ae1 100644 --- a/tests/test_fx_reclaim.cpp +++ b/tests/test_fx_reclaim.cpp @@ -59,6 +59,14 @@ struct FakeHost { int invalidateAfterWrite = -1; int itemsSeenMidDrain = -1; // queue size as the re-entrant caller finds it + // A Ctrl-Z arriving through the same pump: what fxReclaimAbandonForStateLoad does, + // fired on the Nth successful write. The direction probe rides the same hook because + // the property it checks is the same one — what a caller re-entering mid-drain sees. + int abandonAfterWrite = -1; + int probeActivityAfterWrite = -1; + int spanClosesMidDrain = 0; + FxReclaimActivity activityMidDrain = FxReclaimActivity::Idle; + bool rolling = false; // the transport gate's input // Stands in for the shell's one span instance: fx_reclaim.cpp opens it on the first @@ -70,7 +78,10 @@ struct FakeHost { double nowMs() const { return clock; } bool transportRolling() const { return rolling; } + // Both host entry points refuse once abandoned, exactly as fx_reclaim.cpp's do: this + // one ends every REMAINING track, writeOffline below ends the one being written. bool liveChain(const std::string& guid, std::vector& out) { + if (reentrant && reentrant->abandoned()) return false; auto it = chains.find(guid); if (it == chains.end()) return false; out.clear(); @@ -79,6 +90,7 @@ struct FakeHost { } bool writeOffline(const std::string& guid, int fxIndex, bool offline) { + if (reentrant && reentrant->abandoned()) return false; auto it = chains.find(guid); if (it == chains.end()) return false; if (fxIndex < 0 || fxIndex >= static_cast(it->second.size())) return false; @@ -97,6 +109,12 @@ struct FakeHost { reentrant->discard(); reentrant->invalidate(); } + if (reentrant && static_cast(writeLog.size()) == probeActivityAfterWrite) + activityMidDrain = reentrant->activity(clock, rolling); + if (reentrant && static_cast(writeLog.size()) == abandonAfterWrite) { + if (span && span->end()) ++spanClosesMidDrain; // closeSpan, then the stop + reentrant->abandon(); + } return true; } @@ -1197,6 +1215,160 @@ static void testADrainNoSwitchOpenedMintsOnePointOfItsOwn() { CHECK(!span.open()); } +// -- 11. the state-load terminator ------------------------------------------------------ + +static void testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack() { + // The contrast with testAReentrantInvalidationReachesNothingTheDrainIsUsing above, + // which finishes its track's remaining three writes: an ordinary invalidation only + // says the model moved, while a state load says the PROJECT is being replaced under + // us and the block covering these writes has already closed. So a step is atomic only + // up to the abandon — the half-parked chain that leaves is the accepted cost. + ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}"}}, + {"{T2}", {"{FX-C}"}}}); + FakeHost host; + host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}"}); + host.chains["{T2}"] = onlineChain({"{FX-C}"}); + host.stepCostMs = 1.0; // cheap enough that only the abandon can stop the tick + + UndoSpan span; // what a real switch left open + CHECK(span.begin(kProjA, "ReaSampler: activate Design view", 9)); + host.span = &span; + + FxReclaimQueue q = queueOf(vm, {"{T1}", "{T2}"}); + q.run(host); // observes both parked at t=0 + host.clock += kParkHoldMs; + + host.reentrant = &q; + host.abandonAfterWrite = 1; + const ReclaimRunResult r = q.run(host); + + CHECK(host.writeLog == std::vector{"{T1}:0=off"}); + CHECK(host.offlineAt("{T1}", 0)); + CHECK(!host.offlineAt("{T1}", 1)); // the same track, mid-chain — the partial park + CHECK(!host.offlineAt("{T2}", 0)); // and every track after it, untouched + + CHECK(host.spanClosesMidDrain == 1); + CHECK(!span.open()); + CHECK(r.aborted); + CHECK(!r.settled); + CHECK(spanEndsAfter(r)); // the tick's own close still fires... + CHECK(!span.end()); // ...and finds nothing: the terminator got there first + + // The wakeup stands and the work is owed again: going idle here would strand the + // half-parked chain for the rest of the session. + CHECK(q.stale()); + CHECK(q.pending()); + + // ...and the stop belonged to that tick alone. The reloaded project's own drain writes. + host.abandonAfterWrite = -1; + q.adopt(derive(vm, {"{T1}", "{T2}"})); + CHECK(q.run(host).writes == 2); // {T1}'s remaining slot, and {T2}'s whole chain + CHECK(host.offlineAt("{T1}", 1)); + CHECK(host.offlineAt("{T2}", 0)); +} + +// -- 12. the direction the drain is moving ---------------------------------------------- + +static void testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold() { + // The panel bug this surface exists for: `pending` is true from the instant of the + // switch, but for the whole kParkHoldMs the only thing eligible is the RETURNING + // chains' online work — an indicator driven off the queue's mere contents would name + // parking while the drain is doing the opposite. + ViewModeModel vm; + vm.membership().tag("{Z-back}", kArrangeModeId); // active; owed an online + vm.membership().tag("{A-park}", kDesignModeId); // parked; owed an offline + vm.storeSnapshot("{Z-back}", recordWithFx({{"{FX-1}", 0}})); + vm.storeSnapshot("{A-park}", recordWithFx({{"{FX-2}", 0}})); + + FakeHost host; + host.chains["{Z-back}"] = offlineChain({"{FX-1}"}); + host.chains["{A-park}"] = onlineChain({"{FX-2}"}); + host.stepCostMs = 1.0; + + FxReclaimQueue q = queueOf(vm, {"{Z-back}", "{A-park}"}); + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Online); + + CHECK(q.run(host).writes == 1); // the online item only — the offline is still held + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); + + host.clock += kParkHoldMs; + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Offline); + CHECK(q.run(host).settled); + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); +} + +static void testAnOfflineOnlyQueueReadsIdleUntilItsHoldMatures() { + ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}"}); + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + // Idle, not Offline: nothing may move, and the wait is the reason the span is allowed + // to stay open across it. Reporting Offline here is what would light the indicator for + // 3s before a single plugin moves. + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); + CHECK(!q.run(host).settled); // the hold's origin is set by this tick's observe + CHECK(host.writeLog.empty()); + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); + + host.clock += kParkHoldMs; + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Offline); + CHECK(q.run(host).writes == 1); +} + +static void testARollingTransportHidesOfflineWorkFromTheDirectionButNeverOnline() { + ViewModeModel vm; + vm.membership().tag("{Z-back}", kArrangeModeId); + vm.membership().tag("{A-park}", kDesignModeId); + vm.storeSnapshot("{Z-back}", recordWithFx({{"{FX-1}", 0}})); + vm.storeSnapshot("{A-park}", recordWithFx({{"{FX-2}", 0}})); + + FakeHost host; + host.chains["{Z-back}"] = offlineChain({"{FX-1}"}); + host.chains["{A-park}"] = onlineChain({"{FX-2}"}); + host.stepCostMs = 1.0; + host.rolling = true; + + FxReclaimQueue q = queueOf(vm, {"{Z-back}", "{A-park}"}); + // Online is not transport-gated, so it both drains and reads through a rolling + // transport — the second half of the panel bug: this work had no indicator at all. + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Online); + CHECK(q.run(host).writes == 1); + host.clock += kParkHoldMs; + + // The offline half is eligible now and still may not move, so it reads Idle rather + // than naming a direction nothing is travelling in. + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); + CHECK(q.run(host).transportDeferred); + CHECK(host.writeLog.size() == 1); + + host.rolling = false; + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Offline); + CHECK(q.run(host).writes == 1); +} + +static void testTheDirectionHoldsThroughTheDrainsOwnMessagePump() { + // A write pumps, and a repaint landing in there polls this. The queue object holds + // NOTHING for the drain's whole duration (itemsSeenMidDrain == 0, asserted above), so + // without the latch the indicator would read Idle at exactly the moment the plugin + // loads the user is waiting through are happening. + ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}"}}}); + FakeHost host; + host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}"}); + host.stepCostMs = 1.0; + + FxReclaimQueue q = queueOf(vm, {"{T1}"}); + q.run(host); + host.clock += kParkHoldMs; + + host.reentrant = &q; + host.probeActivityAfterWrite = 1; + CHECK(q.run(host).settled); + + CHECK(host.activityMidDrain == FxReclaimActivity::Offline); + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); // latch released +} + int main() { testFastFlipMovesZeroPluginsButAHeldParkStillReclaims(); @@ -1240,6 +1412,13 @@ int main() { testOneSwitchAndItsMultiTickParkingAreOneSpan(); testADrainNoSwitchOpenedMintsOnePointOfItsOwn(); + testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack(); + + testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold(); + testAnOfflineOnlyQueueReadsIdleUntilItsHoldMatures(); + testARollingTransportHidesOfflineWorkFromTheDirectionButNeverOnline(); + testTheDirectionHoldsThroughTheDrainsOwnMessagePump(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }