view: assert the FX-park coalescing delay's debounce; correct four overclaiming doc/comment claims

Extracts parkReadyAt/parkIsReady as a tested pure fold per PLAN.md's phase
criterion; the rest is wording fixes — hitch bound, hazard width, forced-drain
scope, progressive CPU reclaim.
This commit is contained in:
2026-08-03 15:50:07 -04:00
parent 6e2128e937
commit 761125d0fe
4 changed files with 114 additions and 69 deletions
+26 -11
View File
@@ -57,17 +57,32 @@ decide membership or mode rules.
`[verify — DAW]`. **The drain is SPLIT BY INTENT KIND, because only one kind is `[verify — DAW]`. **The drain is SPLIT BY INTENT KIND, because only one kind is
unsafe to persist over.** A pending RESTORE is: flags restored, snapshot already unsafe to persist over.** A pending RESTORE is: flags restored, snapshot already
dropped, FX still offline — a save inside that window records offline FX beside a dropped, FX still offline — a save inside that window records offline FX beside a
model that can no longer replan them, unrecoverable on reopen. So any path that model that can no longer replan them, unrecoverable on reopen. `persistViewState`
serializes the view model drains every restore synchronously first and `render_in_place` are the only two paths that force the restore drain before
(`persistViewState`, `render_in_place`), and an action-driven switch still pays they serialize; roughly ten other `saveToActiveProject` callers (`ingest.cpp`,
the RELOAD hitch before it returns — its repaint and undo block have both closed `bank_ops.cpp`, `bake_land.cpp`, `capture_batch.cpp`, `capture_orchestrator.cpp`,
by then, which is what the deferral was for. Any new caller that reapplies a mode `realtime_lifecycle.cpp`, `panel_input.cpp`) persist without forcing it first — a
and then persists inherits this obligation. A pending PARK is safe to save over: pre-existing, roughly one-tick-wide gap (the idle tick drains restores too),
flags parked, snapshot stored, FX still online, so a reopen replans the park and not known to be exploitable in practice. A new caller that reapplies a mode and
the drain offlines it — it converges. Parks therefore go lazy, ONE FX per idle then persists inherits the forced-drain obligation; one that merely persists does
tick behind a one-second coalescing delay, so the unload half never blocks the not. `drainDeferredFxRestores` is also a silent no-op while a drain is already in
UI at all. The delay is also the width of the live-enumeration hazard at flight (`g_draining`), so a restore forced from inside another drain's
`tickDeferredFxParks` — do not lengthen it casually. message-pump window can itself be skipped — same order of window, same
pre-existing status. An action-driven switch still pays the RELOAD hitch before
it returns — its repaint and undo block have both closed by then, which is what
the deferral was for. A pending PARK is safe to save over: flags parked, snapshot
stored, FX still online, so a reopen replans the park and the drain offlines it —
it converges. Parks therefore go lazy, ONE FX per idle tick behind a one-second
coalescing delay, which bounds each tick's hitch to one FX's unload rather than
eliminating it — see `view_fx_park.h` for why per-track chunking would not help.
CPU reclaim is therefore PROGRESSIVE, completing over FX-count ticks rather than
in one step: a user watching the CPU meter after a switch sees a ramp, not a
drop — in tension with `design-view.md`'s full-CPU-park rationale ("a heavy FX
bench you don't want taxing the CPU while you arrange"). The delay is only ONE term
of the live-enumeration hazard's width: `firstOnlineFx` re-enumerates the chain
every tick, not just the first, so the actual exposure is delay + (FX count ×
tick interval), and for a large chain the second term dominates — see
`tickDeferredFxParks`. Do not lengthen the delay casually.
`[verify — DAW]` whether the switch's `UpdateArrange` paints before the first `[verify — DAW]` whether the switch's `UpdateArrange` paints before the first
drain tick: nothing pumps the message loop between them, so "the switch paints drain tick: nothing pumps the message loop between them, so "the switch paints
first" is an assumption, not an SDK guarantee. first" is an assumption, not an SDK guarantee.
+15 -9
View File
@@ -33,14 +33,19 @@ namespace {
// swallow a rapid A→B→A flip whole — that flip's restore then cancels the park // swallow a rapid A→B→A flip whole — that flip's restore then cancels the park
// outright and the plugins never move at all — and short enough that the window // outright and the plugins never move at all — and short enough that the window
// in which a user could add an FX to the track the switch just hid, and so miss // in which a user could add an FX to the track the switch just hid, and so miss
// the pre-park snapshot, stays implausible. Seconds, not minutes: this delay is // the pre-park snapshot, stays implausible. Seconds, not minutes; this is ONE
// the width of the hazard documented at tickDeferredFxParks. // term of the hazard's width — see tickDeferredFxParks for the other.
constexpr double kParkCoalesceSeconds = 1.0; constexpr double kParkCoalesceSeconds = 1.0;
FxParkQueue g_queue; FxParkQueue g_queue;
ReaProject* g_owner = nullptr; // the project the pending intents were enqueued against ReaProject* g_owner = nullptr; // the project the pending intents were enqueued against
bool g_draining = false; bool g_draining = false;
double g_parkReadyAt = 0.0; // time_precise() stamp; re-armed by every enqueued park // The gate is GLOBAL, not per-track: a park enqueued for track Z re-arms this
// same stamp even though it does nothing to an already-partial park on track A.
// Benign — that track's FX are already I_FXEN=0/B_MAINSEND=0 from the
// synchronous half, so only its CPU reclaim (not correctness) is delayed — but
// this is not a per-intent debounce.
double g_parkReadyAt = 0.0; // parkReadyAt() stamp; re-armed by every enqueued park
// Makes "one drain at a time" explicit rather than implied by the call sites. // Makes "one drain at a time" explicit rather than implied by the call sites.
// RAII because an apply can throw and a stuck flag would silence the queue for // RAII because an apply can throw and a stuck flag would silence the queue for
@@ -81,10 +86,11 @@ int firstOnlineFx(MediaTrack* tr) {
return -1; return -1;
} }
// One track by GUID. The park tick resolves ONE track and does so afresh every // One track by GUID, resolved afresh every tick — caching the handle across
// tick — a whole-project map would be rebuilt per tick for a single lookup, and // ticks is exactly the dangle ValidatePtr2 is here to prevent. Still O(tracks),
// caching the handle across ticks is exactly the dangle ValidatePtr2 is here to // still one guidString() per track per tick; a per-tick unordered_map keyed by
// prevent. // GUID would cost the same enumeration to build for a single lookup, so the only
// thing avoided by NOT building one is that allocation.
MediaTrack* findTrack(ReaProject* proj, const std::string& guid) { MediaTrack* findTrack(ReaProject* proj, const std::string& guid) {
const int count = CountTracks(proj); const int count = CountTracks(proj);
for (int i = 0; i < count; ++i) { for (int i = 0; i < count; ++i) {
@@ -169,7 +175,7 @@ std::vector<FxOfflineOp> deferFxPark(ReaProject* proj, const std::string& guid)
adoptOwner(proj); adoptOwner(proj);
// Re-armed, not set once: while switches keep arriving the park keeps // Re-armed, not set once: while switches keep arriving the park keeps
// waiting, which is the whole point of coalescing them. // waiting, which is the whole point of coalescing them.
g_parkReadyAt = time_precise() + kParkCoalesceSeconds; g_parkReadyAt = parkReadyAt(time_precise(), kParkCoalesceSeconds);
return g_queue.park(guid); return g_queue.park(guid);
} }
@@ -239,7 +245,7 @@ void drainDeferredFxRestores() {
void tickDeferredFxParks() { void tickDeferredFxParks() {
if (!drainGateOpen()) return; if (!drainGateOpen()) return;
if (time_precise() < g_parkReadyAt) return; // still coalescing if (!parkIsReady(time_precise(), g_parkReadyAt)) return; // still coalescing
const std::string guid = g_queue.nextParkGuid(); const std::string guid = g_queue.nextParkGuid();
if (guid.empty()) return; // restores only — not this half's work if (guid.empty()) return; // restores only — not this half's work
+40 -46
View File
@@ -16,6 +16,15 @@ class ReaProject;
namespace reasampler { namespace reasampler {
// The coalescing delay's gate, pure so the debounce can be asserted without a
// DAW clock (test_view_fx_park.cpp). parkReadyAt is called on EVERY enqueue,
// not just the first — that re-arming from each call's own `now` is what makes
// it a debounce rather than a one-shot timer, and is what lets a rapid A→B→A
// flip (three enqueues inside one delay window) cost a single wait measured
// from the last flip rather than three, or one anchored to the first.
inline double parkReadyAt(double now, double coalesceSeconds) { return now + coalesceSeconds; }
inline bool parkIsReady(double now, double readyAt) { return now >= readyAt; }
// The chain as it stands now: identity by current slot. Snapshot, park and // The chain as it stands now: identity by current slot. Snapshot, park and
// restore all address FX through this one plain 0..TrackFX_GetCount-1 // restore all address FX through this one plain 0..TrackFX_GetCount-1
// enumeration — never the 0x1000000/0x2000000 input-FX or container forms — so // enumeration — never the 0x1000000/0x2000000 input-FX or container forms — so
@@ -27,14 +36,11 @@ std::vector<std::string> liveFxGuids(MediaTrack* tr);
struct FxParkIntent { struct FxParkIntent {
std::string guid; std::string guid;
bool park = false; bool park = false;
// A park write is landing or has landed on this track, so the live chain // Set once a park write has landed on this track the live chain then
// matches NEITHER endpoint. Parks apply one FX per idle tick, so this is a // matches NEITHER endpoint, so the cancel-on-inverse rule below is unsafe
// state a park genuinely sits in — and while it holds, the cancel rule // until the intent resumes/supersedes instead of annihilating. Restores
// below is unsafe in both directions: only the restore's own ops can put // carry no such state: they are detached whole and applied in one drain.
// back what a half-finished park offlined, and a park arriving on that // Full contract: src/shell/view/CLAUDE.md's `view_fx_park` entry.
// restore must RESUME rather than annihilate or it strands every FX the
// first pass had not reached. Restores carry no such state: they are
// detached whole and applied in one drain.
bool partial = false; bool partial = false;
std::vector<FxOfflineOp> restoreOps; std::vector<FxOfflineOp> restoreOps;
}; };
@@ -158,22 +164,16 @@ struct PreParkFx {
// The FX half a fresh pre-park snapshot must carry when the park CANCELLED a // The FX half a fresh pre-park snapshot must carry when the park CANCELLED a
// pending restore: those ops are the only surviving record of the pre-park // pending restore: those ops are the only surviving record of the pre-park
// state, because the chain still reads the parked values until that restore // state, because the chain still reads the parked values and the cancel means
// drains — and it never will, the cancel dropped it. Empty in (nothing was // no drain will ever put them back. Empty (nothing cancelled) means the caller
// cancelled) means the caller reads the live chain instead. The restore's own // reads the live chain instead; the restore's own keying travels with it so a
// keying travels with it so a slot-keyed snapshot lifted from a legacy // slot-keyed snapshot does not silently become identity-keyed with no
// view_state does not silently become an identity-keyed one with no identities. // identities. The ops describe the chain as of the ORIGINAL park, not the live
// // chain applyPark re-enumerates at drain time — so an FX added while parked (a
// FAITHFUL TO THE SNAPSHOT, NOT THE CHAIN. The ops describe the chain as it was // floating FX-chain window, ReaScript) is absent here yet still offlined by the
// at the ORIGINAL park; applyPark enumerates it again at drain time. So an FX // cancelling park's drain, and never comes back online. Rare and hand-recoverable.
// added while the track was parked (a floating FX-chain window, ReaScript) is // `offline` widens back from FxOfflineOp's bool to FxOfflineState's defensive int
// absent from this reconstruction yet IS offlined by the cancelling park's // — restoring the type, not adding information.
// drain — and so never comes back online. Rare, and recoverable by hand in the
// FX chain, but specific to the deferral.
//
// `offline` is already boolean by the time it arrives: makeRestorePlan narrowed
// FxOfflineState's defensive int to FxOfflineOp's bool, so this widening back to
// int restores the type, not lost information.
inline PreParkFx preParkFxFromCancelledRestore(const std::vector<FxOfflineOp>& cancelled) { inline PreParkFx preParkFxFromCancelledRestore(const std::vector<FxOfflineOp>& cancelled) {
PreParkFx out; PreParkFx out;
if (cancelled.empty()) return out; if (cancelled.empty()) return out;
@@ -202,33 +202,27 @@ void deferFxRestore(ReaProject* proj, const std::string& guid, std::vector<FxOff
// idle, and both early-out re-entrantly: one drain at a time, the outer one owns // idle, and both early-out re-entrantly: one drain at a time, the outer one owns
// the queue. // the queue.
// //
// RESTORES, applied whole, synchronously. Called on the idle tick AND before the // RESTORES, applied whole, synchronously. Called on the idle tick and forced by
// view model is serialized (persistViewState, render_in_place). The second call // a subset of the paths that serialize the view model. WHICH paths force it, and
// is not an optimization: between a restore's synchronous flag writes and its // why forcing is required, is src/shell/view/CLAUDE.md's Documented-caveat entry
// drain the FX are still offline while the model has already dropped the // — kept there only, not restated here, so the two cannot drift apart.
// snapshot that would replan them, so a save inside that window — deterministic
// under a custom action chain like "toggle mode; save project" — records offline
// FX beside a snapshot-free model, and nothing on reopen brings them back
// online. The cost is that an action-driven switch pays the RELOAD hitch before
// it returns; its repaint and undo block have both closed by then, which is what
// the deferral was for.
void drainDeferredFxRestores(); void drainDeferredFxRestores();
// PARKS, one FX per idle tick behind a short coalescing delay. Nothing waits on // PARKS, one FX per idle tick behind a short coalescing delay (parkReadyAt /
// a park, and unlike a restore it is safe to persist over: the flags are already // parkIsReady above). Unlike a restore it is safe to persist over — it
// parked and the snapshot already stored, so a .rpp saved mid-window reopens with // converges on its own; see CLAUDE.md for why, and for the CPU-reclaim-is-
// the FX online, applyMode replans a park for that inactive leaf, and the drain // progressive consequence.
// offlines it. It converges on its own, which is why this half does not force.
// //
// One FX, not one track: a single convolution reverb or loaded sampler is the // One FX, not one track: a single convolution reverb or loaded sampler is the
// unit of cost, so per-track chunking would not bound the hitch. // unit of cost, so per-track chunking would not bound the hitch — only the
// per-FX split does, and only to ONE FX's unload per tick, not to zero.
// //
// The delay's cost is the width of a documented hazard, not a new one. applyPark // The live-enumeration hazard's width is delay + (FX count × tick interval), NOT
// enumerates the chain LIVE at drain time while the pre-park snapshot was taken // the delay alone: firstOnlineFx re-enumerates the chain LIVE every tick, not
// at switch time, so an FX added inside the window is offlined carrying no // just the first, so an FX added at any point before the park retires is
// snapshot entry — resolveFxRestore then has no op for it and it stays offline. // offlined carrying no snapshot entry — resolveFxRestore then has no op for it
// Same failure as the cancelled-restore case below, over a longer window; keep // and it stays offline. Same failure as the cancelled-restore case above, over a
// the delay short. // longer window. Do not lengthen the delay casually; it is only one term.
void tickDeferredFxParks(); void tickDeferredFxParks();
// Drops every pending intent without applying it. Called when the model the // Drops every pending intent without applying it. Called when the model the
+33 -3
View File
@@ -4,9 +4,11 @@
// The properties under test: a mode switch leaves its per-FX offline work here, // The properties under test: a mode switch leaves its per-FX offline work here,
// so a second switch arriving before the first drained must leave every track in // so a second switch arriving before the first drained must leave every track in
// the state the SECOND switch specifies — never the first's, never both replayed; // the state the SECOND switch specifies — never the first's, never both replayed;
// a cancel must not strand the pre-park FX state it was the last record of; and // a cancel must not strand the pre-park FX state it was the last record of; the
// the restore/park kind split must hold, restores detaching whole while parks // restore/park kind split must hold, restores detaching whole while parks stay
// stay queued across the ticks that apply them one FX at a time. // queued across the ticks that apply them one FX at a time; and the coalescing
// delay debounces from each enqueue's own time rather than firing once per the
// first.
#include "../src/shell/view/view_fx_park.h" #include "../src/shell/view/view_fx_park.h"
@@ -35,6 +37,32 @@ static const FxParkIntent* intentFor(const FxParkQueue& q, const std::string& gu
return nullptr; return nullptr;
} }
// -- the coalescing gate ------------------------------------------------------
static void testCoalesceDebouncesFromEachEnqueuesOwnTimeNotTheFirst() {
double readyAt = parkReadyAt(0.0, 1.0);
CHECK(!parkIsReady(0.9, readyAt));
// A second enqueue arrives before the first's delay elapsed. A true debounce
// re-arms from THIS call's time; a one-shot would leave readyAt at 1.0 and
// this enqueue would have no effect.
readyAt = parkReadyAt(0.5, 1.0);
CHECK(!parkIsReady(1.0, readyAt)); // a one-shot would already be ready here
CHECK(parkIsReady(1.5, readyAt));
}
static void testRapidAToBToAStillCostsOneWaitFromTheLastFlip() {
// Three enqueues inside one delay window (A→B→A) still produce exactly one
// wait, measured from the LAST enqueue — not three separate timers and not
// one anchored to the first.
double readyAt = parkReadyAt(0.0, 1.0);
readyAt = parkReadyAt(0.3, 1.0);
readyAt = parkReadyAt(0.6, 1.0);
CHECK(!parkIsReady(1.5, readyAt));
CHECK(parkIsReady(1.6, readyAt));
}
// -- tests ------------------------------------------------------------------- // -- tests -------------------------------------------------------------------
static void testParkEnqueuesOneIntentCarryingNoOps() { static void testParkEnqueuesOneIntentCarryingNoOps() {
@@ -379,6 +407,8 @@ static void testAReEntrantParkCancelsOnlyWhatIsStillPending() {
} }
int main() { int main() {
testCoalesceDebouncesFromEachEnqueuesOwnTimeNotTheFirst();
testRapidAToBToAStillCostsOneWaitFromTheLastFlip();
testParkEnqueuesOneIntentCarryingNoOps(); testParkEnqueuesOneIntentCarryingNoOps();
testParkReportsNothingCancelledWhenNoIntentWasPending(); testParkReportsNothingCancelledWhenNoIntentWasPending();
testParkOnItsOwnPendingParkReportsNothingCancelled(); testParkOnItsOwnPendingParkReportsNothingCancelled();