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.
This commit is contained in:
2026-08-07 13:45:58 -04:00
parent 86aa8dabba
commit 9b6f725747
5 changed files with 357 additions and 34 deletions
+8
View File
@@ -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
+23 -9
View File
@@ -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
+40 -8
View File
@@ -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<double, std::milli>(
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<double, std::milli>(
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<std::string>& 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<TrackFxDrops>& 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
+107 -17
View File
@@ -122,6 +122,56 @@ inline int nextEligible(const std::vector<ReclaimItem>& 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<ReclaimItem>& 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<std::size_t>(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<ReclaimItem>& 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<std::size_t>(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<ReclaimItem>& 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<std::size_t>(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<ReclaimItem>& 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 <class Host>
ReclaimRunResult run(Host& host) {
const unsigned long long entered = generation_;
abandoned_ = false;
std::vector<ReclaimItem> 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
+179
View File
@@ -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<std::string>& 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<int>(it->second.size())) return false;
@@ -97,6 +109,12 @@ struct FakeHost {
reentrant->discard();
reentrant->invalidate();
}
if (reentrant && static_cast<int>(writeLog.size()) == probeActivityAfterWrite)
activityMidDrain = reentrant->activity(clock, rolling);
if (reentrant && static_cast<int>(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<std::string>{"{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;
}