Gate the FX reclaimer on the transport, both directions; move the release's persist off the drain and onto the project save

The drain no longer dirties a project on its own initiative — a spent park record rides the user's next save via OnSaveExtensionConfig.
This commit is contained in:
2026-08-06 22:31:47 -04:00
parent 224dcf84df
commit 02790f1270
6 changed files with 288 additions and 79 deletions
+11 -4
View File
@@ -250,18 +250,25 @@ static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*r
g_session.requestReload(); g_session.requestReload();
} }
// Intentional no-ops: ReaSampler stores state via project EXT STATE, not this // Intentional no-op: ReaSampler stores state via project EXT STATE, not this
// extension's own project lines. The struct is registered ONLY for the // extension's own project lines.
// BeginLoadProjectState undo/redo notification.
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/, static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
bool /*isUndo*/, project_config_extension_t* /*reg*/) bool /*isUndo*/, project_config_extension_t* /*reg*/)
{ {
return false; // we own no project lines — ext state carries our data 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*/) project_config_extension_t* /*reg*/)
{ {
if (!isUndo)
reasampler::fxReclaimFlushRelease(g_session);
} }
// Storage must outlive registration — REAPER holds this pointer until we unregister it. // Storage must outlive registration — REAPER holds this pointer until we unregister it.
+10
View File
@@ -211,6 +211,16 @@ bool ReaSamplerSession::saveViewStateOnly() {
return true; 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) { bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
ReaProject* proj = activeSavedProject(); ReaProject* proj = activeSavedProject();
if (!proj) return false; if (!proj) return false;
+7
View File
@@ -144,6 +144,13 @@ public:
// Returns on the SAME rule as saveToActiveProject above, prohibition included. // Returns on the SAME rule as saveToActiveProject above, prohibition included.
bool saveViewStateOnly(); 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, // Report-only prune dry-run: feeds the pure core with (present, referenced,
// owned) — `present` from the folder enumeration, the other two from the // owned) — `present` from the folder enumeration, the other two from the
// tracking authority. FAIL-SAFE: tracking state the authority cannot read // tracking authority. FAIL-SAFE: tracking state the authority cannot read
+25 -13
View File
@@ -10,8 +10,8 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "shell/persist/session.h" // saveViewStateOnly — the release's durable half #include "shell/persist/session.h" // flushViewState — the release's durable half
#include "shell/view/view.h" // modeApplyInProgress #include "shell/view/view.h" // modeApplyInProgress, transportBlocksModeSwitch
#include "shell/view/view_park.h" // fxGuidString — the one FX-identity read #include "shell/view/view_park.h" // fxGuidString — the one FX-identity read
#include "shell/view/view_tracks.h" #include "shell/view/view_tracks.h"
@@ -34,6 +34,9 @@ namespace {
// FxReclaimQueue, which owns all three. // FxReclaimQueue, which owns all three.
FxReclaimQueue g_queue; 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 // 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 // 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. // second queue for the same project and race the outer one's writes track for track.
@@ -103,6 +106,11 @@ struct ReclaimHost {
.count(); .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<std::string>& out) { bool liveChain(const std::string& guid, std::vector<std::string>& out) {
MediaTrack* tr = resolveForStep(guid); MediaTrack* tr = resolveForStep(guid);
if (!tr) return false; if (!tr) return false;
@@ -136,17 +144,10 @@ struct ReclaimHost {
// Reached only after the drain has unwound and the queue has proved its generation // 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. // 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); } // The spend lands in memory here and reaches disk on the project's next save.
void releaseFx(const std::string& guid) {
// saveViewStateOnly writes the ACTIVE project and takes none, so re-read that rather session->view().releaseSnapshotFx(guid);
// than trust the project this tick pinned: a write pumps, and a tab switch inside one g_releaseFlush.arm(project);
// 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();
} }
}; };
@@ -212,4 +213,15 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) {
if (!run.aborted && run.settled) g_queue.goIdle(); 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 } // namespace reasampler
+52 -21
View File
@@ -173,18 +173,19 @@ struct ReclaimTickResult {
// fx_reclaim.cpp holds the one live implementation. Host must provide: // fx_reclaim.cpp holds the one live implementation. Host must provide:
// //
// double nowMs() monotonic milliseconds // double nowMs() monotonic milliseconds
// bool transportRolling() playing or recording — the tick
// may move no plugins at all
// bool liveChain(guid, std::vector<std::string>&) FX identity per live slot; // bool liveChain(guid, std::vector<std::string>&) FX identity per live slot;
// false when the track is gone // false when the track is gone
// bool writeOffline(guid, fxIndex, offline) compare-before-write inside its // bool writeOffline(guid, fxIndex, offline) compare-before-write inside its
// own discarded undo block; // own discarded undo block;
// true iff it actually wrote // true iff it actually wrote
// void releaseFx(guid) release the record's FX half; // void releaseFx(guid) release the record's FX half and
// called by FxReclaimQueue::run // arm the save-time flush that
// after the drain has unwound, // makes the spend durable; called
// never from inside the drain // by FxReclaimQueue::run after the
// void persistModel() make the releases just applied // drain has unwound, never from
// durable; run calls it ONCE per // inside the drain
// tick and only after a release
// //
// `items` and `clock` must be the caller's OWN storage, not shared state a re-entered // `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 // call could reach: a write pumps the message loop. FxReclaimQueue::run is what makes
@@ -192,6 +193,18 @@ struct ReclaimTickResult {
template <class Host> template <class Host>
ReclaimTickResult drainTick(std::vector<ReclaimItem>& items, ParkClock& clock, Host& host) { ReclaimTickResult drainTick(std::vector<ReclaimItem>& items, ParkClock& clock, Host& host) {
ReclaimTickResult result; 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()); clock.observe(items, host.nowMs());
const int first = nextEligible(items, clock, host.nowMs()); const int first = nextEligible(items, clock, host.nowMs());
@@ -358,19 +371,9 @@ public:
items_.swap(work); items_.swap(work);
// A release dropped by an abort is re-owed, not lost: the next derivation plans // 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 // the same Online item, its writes all find the chain already there, and the
// release fires then. // release fires then. Applied in MEMORY only — the spend reaches disk on the
if (!tick.released.empty()) { // project's next save (ReleaseFlush), never on the drain's own initiative.
for (const std::string& guid : tick.released) host.releaseFx(guid); 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();
}
return ReclaimRunResult{tick.writes, tick.settled, false, tick.drops}; return ReclaimRunResult{tick.writes, tick.settled, false, tick.drops};
} }
@@ -384,6 +387,30 @@ private:
bool pending_ = false; 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 -------------------------------------------------------- // --- shell entry points --------------------------------------------------------
// The ONE test the timer makes per tick, mirroring the realtime capture's single // 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 // (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. // 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 // 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 // park record's FX half — see FxReclaimQueue::run.
// FxReclaimQueue::run.
void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj); 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 } // namespace reasampler
+179 -37
View File
@@ -2,10 +2,11 @@
// loop is templated on its host precisely so the properties that make it safe to run in // 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 // 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 // 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 — // offline plugins surviving an online, the transport gate, the release reaching disk on
// via a host that invalidates from inside a write — that a re-entered invalidation // the user's save rather than on the drain, and — via a host that invalidates from inside
// reaches nothing the drain is using. The queue owns the project pin and the timer's // a write — that a re-entered invalidation reaches nothing the drain is using. The queue
// wakeup for the same reason: a compare-only pointer makes the tab switch assertable. // 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" #include "../src/shell/view/fx_reclaim.h"
@@ -28,6 +29,11 @@ namespace {
// -- a fake project ------------------------------------------------------------ // -- 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<ReaProject*>(static_cast<std::uintptr_t>(0x1));
ReaProject* const kProjB = reinterpret_cast<ReaProject*>(static_cast<std::uintptr_t>(0x2));
// One live FX: its identity (empty when REAPER reports none) and whether it is offline. // One live FX: its identity (empty when REAPER reports none) and whether it is offline.
struct FakeFx { struct FakeFx {
std::string guid; std::string guid;
@@ -51,7 +57,10 @@ struct FakeHost {
int invalidateAfterWrite = -1; int invalidateAfterWrite = -1;
int itemsSeenMidDrain = -1; // queue size as the re-entrant caller finds it 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; } double nowMs() const { return clock; }
bool transportRolling() const { return rolling; }
bool liveChain(const std::string& guid, std::vector<std::string>& out) { bool liveChain(const std::string& guid, std::vector<std::string>& out) {
auto it = chains.find(guid); 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 // `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 // on disk — the blob a reopen or an undo/redo loadFromProject rebuilds from. The
// and the release actually lands, so a persist ordered BEFORE it would serialize a // drain writes the model and NEVER `persisted`: only saveProject() below does, which
// record that is still held and fail the reload assertions. // is what makes "the spend rides the user's save" assertable.
ViewModeModel* model = nullptr; ViewModeModel* model = nullptr;
ReleaseFlush* flush = nullptr;
ReaProject* project = kProjA;
std::string persisted; std::string persisted;
int persists = 0;
void releaseFx(const std::string& guid) { void releaseFx(const std::string& guid) {
released.insert(guid); released.insert(guid);
if (model) model->releaseSnapshotFx(guid); if (model) model->releaseSnapshotFx(guid);
} if (flush) flush->arm(project);
void persistModel() {
++persists;
if (model) persisted = model->serialize();
} }
bool offlineAt(const std::string& guid, std::size_t slot) const { 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 ------------------------------------------------------------- // -- model helpers -------------------------------------------------------------
TrackSnapshot recordWithFx(std::vector<FxOfflineState> fx) { TrackSnapshot recordWithFx(std::vector<FxOfflineState> fx) {
@@ -178,11 +192,6 @@ FxReclaimQueue queueOf(const ViewModeModel& vm, const std::set<std::string>& liv
return q; 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<ReaProject*>(static_cast<std::uintptr_t>(0x1));
ReaProject* const kProjB = reinterpret_cast<ReaProject*>(static_cast<std::uintptr_t>(0x2));
} // namespace } // namespace
// -- 1. debounce --------------------------------------------------------------- // -- 1. debounce ---------------------------------------------------------------
@@ -505,6 +514,8 @@ static void testAnInvalidationDuringAnOnlineDropsThatTicksRelease() {
vm.releaseSnapshotFlags("{T}"); vm.releaseSnapshotFlags("{T}");
FakeHost host; FakeHost host;
ReleaseFlush flush;
host.flush = &flush;
host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"}); host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"});
FxReclaimQueue q = queueOf(vm, {"{T}"}); FxReclaimQueue q = queueOf(vm, {"{T}"});
@@ -516,7 +527,7 @@ static void testAnInvalidationDuringAnOnlineDropsThatTicksRelease() {
CHECK(r.aborted); CHECK(r.aborted);
CHECK(r.writes == 2); // the item still completed — a step is atomic CHECK(r.writes == 2); // the item still completed — a step is atomic
CHECK(host.released.empty()); // but its record was NOT spent CHECK(host.released.empty()); // but its record was NOT spent
CHECK(host.persists == 0); // and nothing durable claims it was 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 // Re-owed, not lost: the re-derived item finds the chain already where the record
// wants it, writes nothing more, and releases then. // wants it, writes nothing more, and releases then.
@@ -525,45 +536,97 @@ static void testAnInvalidationDuringAnOnlineDropsThatTicksRelease() {
CHECK(q.run(host).settled); CHECK(q.run(host).settled);
CHECK(host.writeLog.size() == 2); CHECK(host.writeLog.size() == 2);
CHECK(host.released.count("{T}") == 1); CHECK(host.released.count("{T}") == 1);
CHECK(host.persists == 1); CHECK(flush.pendingFor(kProjA));
} }
// -- 6. the release has to outlive the session ------------------------------------ // -- 6. the release has to outlive the session ------------------------------------
static void testACompletedOnlineIsPersistedSoAReloadPlansNoWork() { static void testACompletedOnlineReachesDiskOnTheSaveAndNotOnTheDrain() {
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"}); ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"});
vm.setActiveMode(kDesignModeId); vm.setActiveMode(kDesignModeId);
vm.releaseSnapshotFlags("{T}"); 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 // 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. // revert whatever per-FX offline the user set after the reclaim had finished.
const std::optional<ViewModeModel> stale = ViewModeModel::deserialize(vm.serialize());
CHECK(stale.has_value());
CHECK(derive(*stale, {"{T}"}).size() == 1);
FakeHost host; FakeHost host;
host.model = &vm; host.model = &vm;
host.persisted = vm.serialize(); // the project as it sits on disk, record unspent
host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"}); host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"});
ReleaseFlush flush;
host.flush = &flush;
FxReclaimQueue q = queueOf(vm, {"{T}"}); FxReclaimQueue q = queueOf(vm, {"{T}"});
drainToSettled(q, host); drainToSettled(q, host);
CHECK(host.released.count("{T}") == 1); CHECK(host.released.count("{T}") == 1);
CHECK(host.persists == 1); // once per tick that released, not once per release
const std::optional<ViewModeModel> reloaded = ViewModeModel::deserialize(host.persisted); // The drain wrote NOTHING durable. That is the whole change: a background tick that
CHECK(reloaded.has_value()); // persisted would also have dirtied the project, asking the user to save one they
CHECK(reloaded->snapshot("{T}") == nullptr); // both halves spent ⇒ record erased // had only opened.
CHECK(derive(*reloaded, {"{T}"}).empty()); const std::optional<ViewModeModel> 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<ViewModeModel> 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() { static void testAReleaseKilledBeforeTheFlushIsSimplyReEarned() {
// OFFLINE work spends no record, so there is nothing to make durable — and a // The exposure the save-time flush opens: between the in-memory release and the next
// background reclaim must not dirty the project for work it can simply redo. // 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<ViewModeModel> 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}"}); ViewModeModel vm = parkedDesignLeaf({"{FX-A}"});
FakeHost host; FakeHost host;
host.model = &vm; host.model = &vm;
host.chains["{T}"] = onlineChain({"{FX-A}"}); host.chains["{T}"] = onlineChain({"{FX-A}"});
ReleaseFlush flush;
host.flush = &flush;
FxReclaimQueue q = queueOf(vm, {"{T}"}); FxReclaimQueue q = queueOf(vm, {"{T}"});
q.run(host); // observes the hold at t=0 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.writeLog.size() == 1); // it did move the plugin
CHECK(host.released.empty()); CHECK(host.released.empty());
CHECK(host.persists == 0); CHECK(!flush.pendingFor(kProjA));
saveProject(host, flush, kProjA);
CHECK(host.persisted.empty()); CHECK(host.persisted.empty());
} }
@@ -772,6 +836,79 @@ static void testATickThatLostNothingNeitherPrintsNorDisturbsTheDropMemo() {
CHECK(!shouldReportDrops(/*sameOwnerAsLast=*/true, lost, lost)); 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() { int main() {
testFastFlipMovesZeroPluginsButAHeldParkStillReclaims(); testFastFlipMovesZeroPluginsButAHeldParkStillReclaims();
@@ -790,8 +927,9 @@ int main() {
testAnInvalidationDuringTheLastItemLeavesTheTickUnsettled(); testAnInvalidationDuringTheLastItemLeavesTheTickUnsettled();
testAnInvalidationDuringAnOnlineDropsThatTicksRelease(); testAnInvalidationDuringAnOnlineDropsThatTicksRelease();
testACompletedOnlineIsPersistedSoAReloadPlansNoWork(); testACompletedOnlineReachesDiskOnTheSaveAndNotOnTheDrain();
testATickThatSpendsNoRecordPersistsNothing(); testAReleaseKilledBeforeTheFlushIsSimplyReEarned();
testATickThatSpendsNoRecordOwesNoSaveAnything();
testADropIsReportedOnceAcrossRepeatedRederivations(); testADropIsReportedOnceAcrossRepeatedRederivations();
testAnOfflineTickReportsNoDrops(); testAnOfflineTickReportsNoDrops();
@@ -801,6 +939,10 @@ int main() {
testOnlyANoProjectTickClearsTheWakeup(); testOnlyANoProjectTickClearsTheWakeup();
testATickThatLostNothingNeitherPrintsNorDisturbsTheDropMemo(); testATickThatLostNothingNeitherPrintsNorDisturbsTheDropMemo();
testARollingTransportMovesNoPluginsAndKeepsTheHold();
testTheGateStopsTheOnlineDirectionToo();
testAGatedTickStillGoesIdleWithNothingLeftToConverge();
if (g_fail == 0) std::printf("All tests passed.\n"); if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0; return g_fail ? 1 : 0;
} }