Reclaim drain owns its work: invalidation only bumps a generation, and a generation that moved aborts the tick's commit and its release.
This commit is contained in:
+151
-28
@@ -1,8 +1,9 @@
|
||||
// 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
|
||||
// between-track yield, the queue being a cache of intent that a model change discards,
|
||||
// and the user's own offline plugins surviving an online.
|
||||
// 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
|
||||
// yield, the queue being a cache of intent that a model change discards, the user's own
|
||||
// offline plugins surviving an online, and — via a host that invalidates from inside a
|
||||
// write — that a re-entered invalidation reaches nothing the drain is using.
|
||||
|
||||
#include "../src/shell/view/fx_reclaim.h"
|
||||
|
||||
@@ -39,6 +40,13 @@ struct FakeHost {
|
||||
double clock = 0.0;
|
||||
double stepCostMs = 50.0;
|
||||
|
||||
// Models a re-entered timer landing inside TrackFX_SetOffline's message pump: on the
|
||||
// Nth successful write, the write itself does to the queue what a nested applyMode
|
||||
// used to do — clear it and mark it stale.
|
||||
FxReclaimQueue* reentrant = nullptr;
|
||||
int invalidateAfterWrite = -1;
|
||||
int itemsSeenMidDrain = -1; // queue size as the re-entrant caller finds it
|
||||
|
||||
double nowMs() const { return clock; }
|
||||
|
||||
bool liveChain(const std::string& guid, std::vector<std::string>& out) {
|
||||
@@ -58,6 +66,12 @@ struct FakeHost {
|
||||
it->second[static_cast<std::size_t>(fxIndex)].offline = offline;
|
||||
clock += stepCostMs;
|
||||
writeLog.push_back(guid + ":" + std::to_string(fxIndex) + (offline ? "=off" : "=on"));
|
||||
|
||||
if (reentrant && static_cast<int>(writeLog.size()) == invalidateAfterWrite) {
|
||||
itemsSeenMidDrain = static_cast<int>(reentrant->items().size());
|
||||
reentrant->discard();
|
||||
reentrant->invalidate();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -121,15 +135,29 @@ void matureHold(std::vector<ReclaimItem>& items, ParkClock& clock, FakeHost& hos
|
||||
}
|
||||
|
||||
// Runs ticks until the queue settles, bounded so a non-terminating drain fails the test
|
||||
// rather than hanging CTest.
|
||||
// rather than hanging CTest. The two overloads are the two entry points: the pure drain
|
||||
// over the caller's own storage, and the owned queue the shell actually runs.
|
||||
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);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int drainToSettled(FxReclaimQueue& queue, FakeHost& host, int maxTicks = 64) {
|
||||
for (int t = 1; t <= maxTicks; ++t)
|
||||
if (queue.run(host).settled) return t;
|
||||
CHECK(false); // did not settle — the drain is not making forward progress
|
||||
return -1;
|
||||
}
|
||||
|
||||
FxReclaimQueue queueOf(const ViewModeModel& vm, const std::set<std::string>& live) {
|
||||
FxReclaimQueue q;
|
||||
q.adopt(derive(vm, live));
|
||||
return q;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// -- 1. debounce ---------------------------------------------------------------
|
||||
@@ -139,21 +167,20 @@ static void testFastFlipMovesZeroPluginsButAHeldParkStillReclaims() {
|
||||
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
|
||||
FxReclaimQueue q = queueOf(vm, {"{T}"});
|
||||
CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Offline);
|
||||
CHECK(q.run(host).writes == 0);
|
||||
CHECK(!q.run(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);
|
||||
q.adopt(derive(vm, {"{T}"}));
|
||||
CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Online);
|
||||
drainToSettled(q, host);
|
||||
|
||||
// The whole point of the 3s hold: a fast A→B→A flip moved NO plugins.
|
||||
CHECK(host.writeLog.empty());
|
||||
@@ -165,11 +192,10 @@ static void testFastFlipMovesZeroPluginsButAHeldParkStillReclaims() {
|
||||
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
|
||||
FxReclaimQueue q2 = queueOf(held, {"{T}"});
|
||||
CHECK(q2.run(host2).writes == 0); // observed parked at t=0
|
||||
host2.clock = kParkHoldMs;
|
||||
drainToSettled(items2, clock2, host2);
|
||||
drainToSettled(q2, 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
|
||||
@@ -329,10 +355,9 @@ static void testAPluginTheUserOfflinedIsNeverOnlinedByTheDrain() {
|
||||
|
||||
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);
|
||||
FxReclaimQueue q = queueOf(vm, {"{T}"});
|
||||
drainToSettled(q, host);
|
||||
|
||||
CHECK(!host.offlineAt("{T}", 0));
|
||||
CHECK(host.offlineAt("{T}", 1)); // still offline — never blanket-onlined
|
||||
@@ -360,11 +385,10 @@ static void testSlotKeyedOnlineRecordRestoresByPosition() {
|
||||
// Dearer per plugin than the whole online budget: the step still runs the chain to
|
||||
// the end, because the budget cannot split one track's move.
|
||||
host.stepCostMs = kOnlineBudgetMs + 10.0;
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}"});
|
||||
CHECK(items.size() == 1);
|
||||
CHECK(drainToSettled(items, clock, host) == 1);
|
||||
FxReclaimQueue q = queueOf(vm, {"{T}"});
|
||||
CHECK(q.items().size() == 1);
|
||||
CHECK(drainToSettled(q, host) == 1);
|
||||
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));
|
||||
@@ -373,15 +397,110 @@ static void testSlotKeyedOnlineRecordRestoresByPosition() {
|
||||
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);
|
||||
FxReclaimQueue q = queueOf(vm, {"{T}"});
|
||||
CHECK(!q.run(host).settled); // observes the hold at t=0; not matured yet
|
||||
host.clock += kParkHoldMs;
|
||||
CHECK(q.run(host).settled);
|
||||
CHECK(host.writeLog.empty());
|
||||
CHECK(host.released.empty()); // the next apply's reconcile drops the whole record
|
||||
}
|
||||
|
||||
// -- 5. re-entrancy: an invalidation arriving with a drain on the stack ------------
|
||||
|
||||
static void testAReentrantInvalidationReachesNothingTheDrainIsUsing() {
|
||||
// The second of {T1}'s four writes re-enters and does what a nested applyMode used to
|
||||
// do to the shared queue: clear it. The drain is mid-item at that moment.
|
||||
ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"}},
|
||||
{"{T2}", {"{FX-E}"}}});
|
||||
FakeHost host;
|
||||
host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"});
|
||||
host.chains["{T2}"] = onlineChain({"{FX-E}"});
|
||||
host.stepCostMs = 50.0;
|
||||
|
||||
FxReclaimQueue q = queueOf(vm, {"{T1}", "{T2}"});
|
||||
q.run(host); // observes both parked at t=0
|
||||
host.clock += kParkHoldMs;
|
||||
|
||||
host.reentrant = &q;
|
||||
host.invalidateAfterWrite = 2;
|
||||
const ReclaimRunResult r = q.run(host);
|
||||
|
||||
// The ownership claim: for the whole length of the drain the queue object holds
|
||||
// nothing, so there is no element for a re-entrant caller to destroy.
|
||||
CHECK(host.itemsSeenMidDrain == 0);
|
||||
|
||||
// The drain finished the track it was on out of its own copy — a step is atomic.
|
||||
const std::vector<std::string> whole{"{T1}:0=off", "{T1}:1=off", "{T1}:2=off",
|
||||
"{T1}:3=off"};
|
||||
CHECK(host.writeLog == whole);
|
||||
CHECK(r.writes == 4);
|
||||
|
||||
// ...and then refused to commit work derived from a model that has since moved.
|
||||
CHECK(r.aborted);
|
||||
CHECK(!r.settled);
|
||||
CHECK(q.stale());
|
||||
CHECK(q.items().empty());
|
||||
}
|
||||
|
||||
static void testAnInvalidationDuringTheLastItemLeavesTheTickUnsettled() {
|
||||
// One item, so the drain would otherwise report settled and the shell would go idle —
|
||||
// clearing the pending flag the re-entrant invalidation had just raised and stranding
|
||||
// its work until the next mode switch.
|
||||
ViewModeModel vm = parkedDesignLeaf({"{FX-A}"});
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = onlineChain({"{FX-A}"});
|
||||
|
||||
FxReclaimQueue q = queueOf(vm, {"{T}"});
|
||||
q.run(host);
|
||||
host.clock += kParkHoldMs;
|
||||
|
||||
host.reentrant = &q;
|
||||
host.invalidateAfterWrite = 1;
|
||||
const ReclaimRunResult r = q.run(host);
|
||||
CHECK(r.writes == 1);
|
||||
CHECK(!r.settled); // the one bit that decides whether the shell clears its wakeup
|
||||
CHECK(r.aborted);
|
||||
CHECK(q.stale()); // and the next tick re-derives rather than finding an empty queue
|
||||
|
||||
// The contrast, so the assertion above is about the invalidation and not about this
|
||||
// queue being unable to settle: the same drain settles once nothing re-enters it.
|
||||
host.reentrant = nullptr;
|
||||
q.adopt(derive(vm, {"{T}"}));
|
||||
CHECK(q.run(host).settled);
|
||||
}
|
||||
|
||||
static void testAnInvalidationDuringAnOnlineDropsThatTicksRelease() {
|
||||
// Releasing spends the record's FX half — the only account of which plugins the USER
|
||||
// had offline before the park. An invalidation mid-drain is the signal that the model
|
||||
// underneath may have been replaced (a project load replaces it in place), so the
|
||||
// release must not land on it.
|
||||
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"});
|
||||
vm.setActiveMode(kDesignModeId);
|
||||
vm.releaseSnapshotFlags("{T}");
|
||||
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"});
|
||||
|
||||
FxReclaimQueue q = queueOf(vm, {"{T}"});
|
||||
CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Online);
|
||||
host.reentrant = &q;
|
||||
host.invalidateAfterWrite = 1;
|
||||
|
||||
const ReclaimRunResult r = q.run(host);
|
||||
CHECK(r.aborted);
|
||||
CHECK(r.writes == 2); // the item still completed — a step is atomic
|
||||
CHECK(host.released.empty()); // but its record was NOT spent
|
||||
|
||||
// Re-owed, not lost: the re-derived item finds the chain already where the record
|
||||
// wants it, writes nothing more, and releases then.
|
||||
host.reentrant = nullptr;
|
||||
q.adopt(derive(vm, {"{T}"}));
|
||||
CHECK(q.run(host).settled);
|
||||
CHECK(host.writeLog.size() == 2);
|
||||
CHECK(host.released.count("{T}") == 1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFastFlipMovesZeroPluginsButAHeldParkStillReclaims();
|
||||
|
||||
@@ -396,6 +515,10 @@ int main() {
|
||||
testSlotKeyedOnlineRecordRestoresByPosition();
|
||||
testATrackThatLeftTheProjectCompletesRatherThanStalls();
|
||||
|
||||
testAReentrantInvalidationReachesNothingTheDrainIsUsing();
|
||||
testAnInvalidationDuringTheLastItemLeavesTheTickUnsettled();
|
||||
testAnInvalidationDuringAnOnlineDropsThatTicksRelease();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user