Add the FX reclaimer: a debounced, time-boxed convergence loop that offlines parked chains and restores returning ones off the switch path
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
// Standalone tests for the FX reclaimer's drain — no REAPER, no test framework. The
|
||||
// loop is templated on its host precisely so the four properties that make it safe to
|
||||
// run in the background are assertable here: the debounce, the per-tick budget's
|
||||
// yield-and-resume, the queue being a cache of intent that a model change discards, and
|
||||
// the user's own offline plugins surviving an online.
|
||||
|
||||
#include "../src/shell/view/fx_reclaim.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::view;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
namespace {
|
||||
|
||||
// -- a fake project ------------------------------------------------------------
|
||||
|
||||
// One live FX: its identity (empty when REAPER reports none) and whether it is offline.
|
||||
struct FakeFx {
|
||||
std::string guid;
|
||||
bool offline = false;
|
||||
};
|
||||
|
||||
// Stands in for the REAPER half of drainTick's Host contract. The clock advances only
|
||||
// on a REAL write, mirroring the live host: a compare-before-write that finds the FX
|
||||
// already where it wants it costs a read, not a plugin load.
|
||||
struct FakeHost {
|
||||
std::map<std::string, std::vector<FakeFx>> chains;
|
||||
std::set<std::string> released;
|
||||
std::vector<std::string> writeLog;
|
||||
double clock = 0.0;
|
||||
double stepCostMs = 50.0;
|
||||
|
||||
double nowMs() const { return clock; }
|
||||
|
||||
bool liveChain(const std::string& guid, std::vector<std::string>& out) {
|
||||
auto it = chains.find(guid);
|
||||
if (it == chains.end()) return false;
|
||||
out.clear();
|
||||
for (const FakeFx& fx : it->second) out.push_back(fx.guid);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool writeOffline(const std::string& guid, int fxIndex, bool offline) {
|
||||
auto it = chains.find(guid);
|
||||
if (it == chains.end()) return false;
|
||||
if (fxIndex < 0 || fxIndex >= static_cast<int>(it->second.size())) return false;
|
||||
if (it->second[static_cast<std::size_t>(fxIndex)].offline == offline) return false;
|
||||
|
||||
it->second[static_cast<std::size_t>(fxIndex)].offline = offline;
|
||||
clock += stepCostMs;
|
||||
writeLog.push_back(guid + ":" + std::to_string(fxIndex) + (offline ? "=off" : "=on"));
|
||||
return true;
|
||||
}
|
||||
|
||||
void releaseFx(const std::string& guid) { released.insert(guid); }
|
||||
|
||||
bool offlineAt(const std::string& guid, std::size_t slot) const {
|
||||
auto it = chains.find(guid);
|
||||
return it != chains.end() && slot < it->second.size() && it->second[slot].offline;
|
||||
}
|
||||
};
|
||||
|
||||
// -- model helpers -------------------------------------------------------------
|
||||
|
||||
TrackSnapshot recordWithFx(std::vector<FxOfflineState> fx) {
|
||||
TrackSnapshot snap;
|
||||
snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 1; snap.fxEnable = 1;
|
||||
snap.fxOffline = std::move(fx);
|
||||
return snap;
|
||||
}
|
||||
|
||||
// What the shell does on every invalidation: throw the queue away and recompute it.
|
||||
std::vector<ReclaimItem> derive(const ViewModeModel& vm, const std::set<std::string>& live) {
|
||||
return makeQueue(desiredFxState(vm, vm.activeModeId(), live));
|
||||
}
|
||||
|
||||
std::vector<FakeFx> onlineChain(const std::vector<std::string>& guids) {
|
||||
std::vector<FakeFx> chain;
|
||||
for (const std::string& g : guids) chain.push_back(FakeFx{g, false});
|
||||
return chain;
|
||||
}
|
||||
|
||||
std::vector<FakeFx> offlineChain(const std::vector<std::string>& guids) {
|
||||
std::vector<FakeFx> chain;
|
||||
for (const std::string& g : guids) chain.push_back(FakeFx{g, true});
|
||||
return chain;
|
||||
}
|
||||
|
||||
// A Design leaf the tool has parked: record held, both halves, nothing user-offlined.
|
||||
ViewModeModel parkedDesignLeaf(const std::vector<std::string>& fxGuids) {
|
||||
ViewModeModel vm;
|
||||
vm.membership().tag("{T}", kDesignModeId); // active mode is Arrange ⇒ {T} is parked
|
||||
std::vector<FxOfflineState> fx;
|
||||
for (const std::string& g : fxGuids) fx.push_back(FxOfflineState{g, 0});
|
||||
vm.storeSnapshot("{T}", recordWithFx(fx));
|
||||
return vm;
|
||||
}
|
||||
|
||||
// Puts the queue's offline items past their debounce the way the live loop does: the
|
||||
// hold starts at the FIRST observation, so advancing the clock before that one just
|
||||
// moves the origin and matures nothing.
|
||||
void matureHold(std::vector<ReclaimItem>& items, ParkClock& clock, FakeHost& host) {
|
||||
clock.observe(items, host.nowMs());
|
||||
host.clock += kParkHoldMs;
|
||||
}
|
||||
|
||||
// Runs ticks until the queue settles, bounded so a non-terminating drain fails the test
|
||||
// rather than hanging CTest.
|
||||
int drainToSettled(std::vector<ReclaimItem>& items, ParkClock& clock, FakeHost& host,
|
||||
int maxTicks = 64) {
|
||||
for (int t = 1; t <= maxTicks; ++t)
|
||||
if (drainTick(items, clock, host).settled) return t;
|
||||
CHECK(false); // did not settle — the drain is not making forward progress
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// -- 1. debounce ---------------------------------------------------------------
|
||||
|
||||
static void testFastFlipMovesZeroPluginsButAHeldParkStillReclaims() {
|
||||
// A→B: {T} parks. The reclaimer must not touch it yet.
|
||||
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}"});
|
||||
CHECK(items.size() == 1 && items[0].work.kind == FxWorkKind::Offline);
|
||||
CHECK(drainTick(items, clock, host).writes == 0);
|
||||
CHECK(!drainTick(items, clock, host).settled); // waiting out its hold, not finished
|
||||
|
||||
// B→A half a second later: the flags come back and the FX half is still held, so the
|
||||
// reclaimer is now owed an ONLINE — over a chain it never got round to offlining.
|
||||
host.clock = 500.0;
|
||||
vm.setActiveMode(kDesignModeId);
|
||||
vm.releaseSnapshotFlags("{T}");
|
||||
items = derive(vm, {"{T}"});
|
||||
CHECK(items.size() == 1 && items[0].work.kind == FxWorkKind::Online);
|
||||
drainToSettled(items, clock, host);
|
||||
|
||||
// The whole point of the 3s hold: a fast A→B→A flip moved NO plugins.
|
||||
CHECK(host.writeLog.empty());
|
||||
CHECK(!host.offlineAt("{T}", 0) && !host.offlineAt("{T}", 1) && !host.offlineAt("{T}", 2));
|
||||
CHECK(host.released.count("{T}") == 1); // the chain is verifiably online; record spent
|
||||
|
||||
// The contrast, so the assertion above is about the hold and not about the drain
|
||||
// being inert: the same park held past 3s DOES reclaim.
|
||||
ViewModeModel held = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
FakeHost host2;
|
||||
host2.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
ParkClock clock2;
|
||||
std::vector<ReclaimItem> items2 = derive(held, {"{T}"});
|
||||
CHECK(drainTick(items2, clock2, host2).writes == 0); // observed parked at t=0
|
||||
host2.clock = kParkHoldMs;
|
||||
drainToSettled(items2, clock2, host2);
|
||||
CHECK(host2.writeLog.size() == 3);
|
||||
CHECK(host2.offlineAt("{T}", 0) && host2.offlineAt("{T}", 1) && host2.offlineAt("{T}", 2));
|
||||
CHECK(host2.released.empty()); // an OFFLINE never releases — the record is still owed
|
||||
}
|
||||
|
||||
// -- 2. budget: yield and resume ------------------------------------------------
|
||||
|
||||
static void testOfflineDrainYieldsOnBudgetAndResumesWhereItStopped() {
|
||||
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"});
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"});
|
||||
host.stepCostMs = 50.0; // dearer than kOfflineBudgetMs ⇒ exactly one plugin per tick
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}"});
|
||||
matureHold(items, clock, host);
|
||||
CHECK(drainTick(items, clock, host).writes == 1);
|
||||
CHECK(items[0].work.resumeAfterSlot == 0);
|
||||
CHECK(items[0].work.resumeAfterFxGuid == "{FX-A}");
|
||||
CHECK(!items[0].done);
|
||||
|
||||
CHECK(drainTick(items, clock, host).writes == 1);
|
||||
CHECK(items[0].work.resumeAfterSlot == 1);
|
||||
|
||||
// Slots 2 and 3, one per tick; the tick that writes the last one also exhausts the
|
||||
// item's write list, so it settles in the same pass rather than costing a spare tick.
|
||||
CHECK(drainToSettled(items, clock, host) == 2);
|
||||
|
||||
// Each slot moved exactly once and in order — no slot re-walked across a yield.
|
||||
const std::vector<std::string> expected{"{T}:0=off", "{T}:1=off", "{T}:2=off", "{T}:3=off"};
|
||||
CHECK(host.writeLog == expected);
|
||||
}
|
||||
|
||||
static void testOnlineDrainGetsTheLargerBudget() {
|
||||
// Same per-plugin cost, same chain length: the only difference is the direction, and
|
||||
// onlining is the one the user is waiting on.
|
||||
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"});
|
||||
vm.setActiveMode(kDesignModeId);
|
||||
vm.releaseSnapshotFlags("{T}");
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"});
|
||||
host.stepCostMs = 50.0;
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}"});
|
||||
CHECK(items.size() == 1 && items[0].work.kind == FxWorkKind::Online);
|
||||
CHECK(drainTick(items, clock, host).writes == 3); // 120ms budget vs 30ms
|
||||
}
|
||||
|
||||
static void testATickStartedOnOnlineWorkDoesNotSpendIntoOfflineWork() {
|
||||
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; // cheap enough that only the kind break can stop the tick
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{Z-back}", "{A-park}"});
|
||||
CHECK(items.size() == 2);
|
||||
CHECK(items[0].work.kind == FxWorkKind::Online); // fx_state's ordering
|
||||
matureHold(items, clock, host); // the parked track's hold is no longer the blocker
|
||||
|
||||
CHECK(drainTick(items, clock, host).writes == 1);
|
||||
CHECK(host.writeLog == std::vector<std::string>{"{Z-back}:0=on"});
|
||||
CHECK(!host.offlineAt("{A-park}", 0)); // opportunistic reclaim waits for its own tick
|
||||
|
||||
CHECK(drainTick(items, clock, host).writes == 1);
|
||||
CHECK(host.offlineAt("{A-park}", 0));
|
||||
}
|
||||
|
||||
// -- 3. invalidation: the queue is a cache of intent ----------------------------
|
||||
|
||||
static void testModelChangeDiscardsDrainProgressButNotTheParkHold() {
|
||||
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
vm.membership().tag("{U}", kDesignModeId); // a second parked leaf, left alone below
|
||||
vm.storeSnapshot("{U}", recordWithFx({{"{FX-U}", 0}}));
|
||||
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
host.chains["{U}"] = onlineChain({"{FX-U}"});
|
||||
host.stepCostMs = 50.0;
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}", "{U}"});
|
||||
drainTick(items, clock, host); // observes both parked at t=0
|
||||
host.clock = kParkHoldMs + 100.0;
|
||||
drainTick(items, clock, host); // one plugin moves; {T} now carries a cursor
|
||||
CHECK(items[0].work.resumeAfterSlot >= 0);
|
||||
|
||||
// The model changes: {T} is retagged into the active mode and restored, while {U}
|
||||
// stays exactly as parked as it was. The queue is discarded whole — direction,
|
||||
// cursors, completion marks and all — and re-derived.
|
||||
vm.membership().tag("{T}", kArrangeModeId);
|
||||
vm.releaseSnapshotFlags("{T}");
|
||||
items = derive(vm, {"{T}", "{U}"});
|
||||
CHECK(items.size() == 2);
|
||||
CHECK(items[0].work.kind == FxWorkKind::Online);
|
||||
CHECK(items[0].work.trackGuid == "{T}");
|
||||
CHECK(items[0].work.resumeAfterSlot == -1); // no drain progress survives a re-derive
|
||||
CHECK(items[0].work.resumeAfterFxGuid.empty());
|
||||
CHECK(!items[0].done);
|
||||
|
||||
// ...but {U}'s hold does survive it. {U} never stopped being parked, and restarting
|
||||
// its 3s on every unrelated model change would defer its reclaim indefinitely.
|
||||
clock.observe(items, host.clock);
|
||||
CHECK(clock.matured("{U}", host.clock));
|
||||
CHECK(nextEligible(items, clock, host.clock) == 0); // online first, then {U} available
|
||||
|
||||
// The mirror: a track that LEAVES the offline set and comes back starts a fresh hold.
|
||||
// {T} was dropped from the clock by the observation above, while it was online.
|
||||
vm.membership().tag("{T}", kDesignModeId);
|
||||
items = derive(vm, {"{T}", "{U}"});
|
||||
clock.observe(items, host.clock);
|
||||
CHECK(!clock.matured("{T}", host.clock));
|
||||
CHECK(!clock.matured("{T}", host.clock + kParkHoldMs - 1.0));
|
||||
CHECK(clock.matured("{T}", host.clock + kParkHoldMs));
|
||||
CHECK(clock.matured("{U}", host.clock)); // and {U}'s own hold still is not restarted
|
||||
}
|
||||
|
||||
// -- 4. the user's own offline plugins ------------------------------------------
|
||||
|
||||
static void testAPluginTheUserOfflinedIsNeverOnlinedByTheDrain() {
|
||||
// {FX-B} was offline before the park because the USER put it there; the record is
|
||||
// the only account of that, and an online must honor it.
|
||||
ViewModeModel vm;
|
||||
vm.membership().tag("{T}", kDesignModeId);
|
||||
vm.setActiveMode(kDesignModeId);
|
||||
vm.storeSnapshot("{T}", recordWithFx({{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}}));
|
||||
vm.releaseSnapshotFlags("{T}");
|
||||
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); // fully parked
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}"});
|
||||
drainToSettled(items, clock, host);
|
||||
|
||||
CHECK(!host.offlineAt("{T}", 0));
|
||||
CHECK(host.offlineAt("{T}", 1)); // still offline — never blanket-onlined
|
||||
CHECK(!host.offlineAt("{T}", 2));
|
||||
for (const std::string& entry : host.writeLog) CHECK(entry != "{T}:1=on");
|
||||
CHECK(host.writeLog.size() == 2);
|
||||
CHECK(host.released.count("{T}") == 1);
|
||||
}
|
||||
|
||||
// -- forward progress: the cursor cases that decide termination -----------------
|
||||
|
||||
static void testResumeCursorAdvancesPastAnFxWithNoIdentity() {
|
||||
// The case Wave 1's slot cursor exists for. Slot 1 is an FX REAPER reports no GUID
|
||||
// for; with the identity alone the cursor would read as "nothing done yet" and the
|
||||
// item would re-walk from slot 0 on every tick.
|
||||
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = onlineChain({"{FX-A}", "", "{FX-C}"});
|
||||
host.stepCostMs = 50.0; // one plugin per tick on the offline budget
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}"});
|
||||
matureHold(items, clock, host);
|
||||
drainTick(items, clock, host);
|
||||
drainTick(items, clock, host);
|
||||
CHECK(items[0].work.resumeAfterFxGuid.empty()); // slot 1 has no identity to record
|
||||
CHECK(items[0].work.resumeAfterSlot == 1); // ...so the slot is what carries it
|
||||
|
||||
CHECK(drainToSettled(items, clock, host) == 1); // slot 2, and the item is exhausted
|
||||
const std::vector<std::string> expected{"{T}:0=off", "{T}:1=off", "{T}:2=off"};
|
||||
CHECK(host.writeLog == expected);
|
||||
}
|
||||
|
||||
static void testSlotKeyedOnlineRecordCarriesItsCursorAsASlot() {
|
||||
// A record lifted from a project saved before FX identity existed: its targets hold
|
||||
// no identity, so a cursor taken from the LIVE identity would match none of them and
|
||||
// the item would re-walk its whole chain after every yield.
|
||||
ViewModeModel vm;
|
||||
vm.membership().tag("{T}", kDesignModeId);
|
||||
vm.setActiveMode(kDesignModeId);
|
||||
TrackSnapshot snap = recordWithFx({{"", 0}, {"", 1}, {"", 0}});
|
||||
snap.fxKeying = FxKeying::Slot;
|
||||
vm.storeSnapshot("{T}", snap);
|
||||
vm.releaseSnapshotFlags("{T}");
|
||||
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = offlineChain({"{FX-X}", "{FX-Y}", "{FX-Z}"});
|
||||
host.stepCostMs = kOnlineBudgetMs + 10.0; // one plugin per tick, then yield
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}"});
|
||||
CHECK(items.size() == 1 && resumeBySlotOnly(items[0].work));
|
||||
drainTick(items, clock, host);
|
||||
CHECK(items[0].work.resumeAfterFxGuid.empty()); // NOT "{FX-X}", which matches no target
|
||||
CHECK(items[0].work.resumeAfterSlot == 0);
|
||||
|
||||
drainToSettled(items, clock, host);
|
||||
const std::vector<std::string> expected{"{T}:0=on", "{T}:2=on"};
|
||||
CHECK(host.writeLog == expected); // slot 1 stays offline, as the record captured
|
||||
CHECK(host.offlineAt("{T}", 1));
|
||||
}
|
||||
|
||||
static void testATrackThatLeftTheProjectCompletesRatherThanStalls() {
|
||||
ViewModeModel vm = parkedDesignLeaf({"{FX-A}"});
|
||||
FakeHost host; // no chain registered ⇒ liveChain reports the track gone
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}"});
|
||||
matureHold(items, clock, host);
|
||||
CHECK(drainTick(items, clock, host).settled);
|
||||
CHECK(host.writeLog.empty());
|
||||
CHECK(host.released.empty()); // the next apply's reconcile drops the whole record
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFastFlipMovesZeroPluginsButAHeldParkStillReclaims();
|
||||
|
||||
testOfflineDrainYieldsOnBudgetAndResumesWhereItStopped();
|
||||
testOnlineDrainGetsTheLargerBudget();
|
||||
testATickStartedOnOnlineWorkDoesNotSpendIntoOfflineWork();
|
||||
|
||||
testModelChangeDiscardsDrainProgressButNotTheParkHold();
|
||||
|
||||
testAPluginTheUserOfflinedIsNeverOnlinedByTheDrain();
|
||||
|
||||
testResumeCursorAdvancesPastAnFxWithNoIdentity();
|
||||
testSlotKeyedOnlineRecordCarriesItsCursorAsASlot();
|
||||
testATrackThatLeftTheProjectCompletesRatherThanStalls();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user