fix(pS-usage): fail-safe prune protection — in-wire owner nonce + sticky union poison, protect-all on zero identified, abort on unreadable record, rsusage_ prefix
This commit is contained in:
+300
-72
@@ -1,19 +1,26 @@
|
||||
// Standalone tests for reasampler::sample_usage — no REAPER, no framework.
|
||||
// The pS-usage seam: instances publish held captures; the extension folds live
|
||||
// instances' holds into the prune's `referenced` set. Tested hard here because this is
|
||||
// the prune-protection guarantee: a capture held by a live instance must be IMPOSSIBLE
|
||||
// to prune (the composed proof at the bottom links prune_reconcile and shows
|
||||
// pruneOrphans can never emit a held path), while a stale record from a deleted
|
||||
// instance must NOT permanently block reclaim (the liveness fold).
|
||||
// the prune-protection guarantee — a SAFETY property: every failure, ambiguity, or
|
||||
// uncertainty must FAIL-SAFE toward PROTECT (over-protection acceptable;
|
||||
// under-protection = deleting a maybe-used file is a data-loss bug).
|
||||
//
|
||||
// Covers: wire round-trip (empty / adversarial bytes), malformed -> nullopt, the
|
||||
// publish plan's four branches (fresh key / clean replace / same-track union /
|
||||
// cross-track re-mint) + skipWrite idempotence, the liveness fold (live, dead-track,
|
||||
// empty-guid fallback, de-dup), and the composed pruneOrphans exclusion proof.
|
||||
// Covers: wire round-trip (nonce + unioned flag, empty / adversarial bytes),
|
||||
// malformed -> nullopt, the publish plan's branches (fresh / clean replace + skip /
|
||||
// sibling union with the sticky poison flag / cross-track re-mint / undecodable heal),
|
||||
// the SAME-TRACK SIBLING repro (the review's 🔴#1 — byte-identical wire convergence
|
||||
// must never let one sibling clean-replace the other's still-held paths, including one
|
||||
// write later via the poison flag), the liveness fold (live, dead-track, empty-guid
|
||||
// fallback, de-dup), the ZERO-IDENTIFIED protect-all net (🔴#2 — an identity-matcher
|
||||
// failure must protect everything, not nothing), the UNREADABLE-record abort
|
||||
// (foldUsageRecords.abortPrune — prune halts, deletes nothing), the pure identity
|
||||
// matcher (UID hex / module filename base / display name, beta over-protect), and the
|
||||
// composed pruneOrphans exclusion proof.
|
||||
|
||||
#include "../src/sample_usage.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
@@ -28,37 +35,49 @@ static int g_fail = 0;
|
||||
|
||||
namespace {
|
||||
|
||||
UsageRecord makeRecord(const std::string& trackGuid,
|
||||
std::vector<UsageHold> holds) {
|
||||
UsageRecord makeRecord(const std::string& trackGuid, const std::string& nonce,
|
||||
std::vector<UsageHold> holds, bool unioned = false) {
|
||||
UsageRecord r;
|
||||
r.trackGuid = trackGuid;
|
||||
r.ownerNonce = nonce;
|
||||
r.unioned = unioned;
|
||||
r.holds = std::move(holds);
|
||||
return r;
|
||||
}
|
||||
|
||||
bool holdsContainPath(const std::vector<UsageHold>& holds, const std::string& path) {
|
||||
for (const UsageHold& h : holds)
|
||||
if (h.relativePath == path) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- wire round-trip ----------------------------------------------------------
|
||||
|
||||
static void testRoundTrip() {
|
||||
const UsageRecord rec = makeRecord(
|
||||
"{12345678-1234-1234-1234-1234567890AB}",
|
||||
"{12345678-1234-1234-1234-1234567890AB}", "aabbccdd00112233",
|
||||
{UsageHold{"cap-1700-kick", "reasampler_bank/kick.wav"},
|
||||
UsageHold{"cap-1701-snare", "reasampler_bank/snare.wav"}});
|
||||
UsageHold{"cap-1701-snare", "reasampler_bank/snare.wav"}},
|
||||
/*unioned=*/true);
|
||||
const std::string wire = encodeUsageRecord(rec);
|
||||
auto back = decodeUsageRecord(wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == rec);
|
||||
CHECK(back->unioned);
|
||||
CHECK(encodeUsageRecord(*back) == wire); // deterministic re-encode
|
||||
}
|
||||
|
||||
static void testRoundTripEmptyHoldsAndEmptyGuid() {
|
||||
// An empty-holds record is LEGAL (an instance releasing everything it held), and an
|
||||
// empty trackGuid is legal (no track context at publish -> any-instance fallback).
|
||||
const UsageRecord rec = makeRecord("", {});
|
||||
const UsageRecord rec = makeRecord("", "", {});
|
||||
auto back = decodeUsageRecord(encodeUsageRecord(rec));
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->trackGuid.empty());
|
||||
CHECK(back->ownerNonce.empty());
|
||||
CHECK(!back->unioned);
|
||||
CHECK(back->holds.empty());
|
||||
}
|
||||
|
||||
@@ -66,7 +85,7 @@ static void testRoundTripAdversarialBytes() {
|
||||
// Ids/paths carrying the wire's own metacharacters must survive whole (the whole
|
||||
// point of length-prefixing): ':' delimiters, digits, the magic tag itself.
|
||||
const UsageRecord rec = makeRecord(
|
||||
"12:34:guid-with-colons",
|
||||
"12:34:guid-with-colons", "1:0",
|
||||
{UsageHold{"rsusage1-lookalike", "path with spaces/and:colons/7:x.wav"}});
|
||||
auto back = decodeUsageRecord(encodeUsageRecord(rec));
|
||||
CHECK(back.has_value());
|
||||
@@ -77,15 +96,19 @@ static void testDecodeMalformed() {
|
||||
CHECK(!decodeUsageRecord("").has_value());
|
||||
CHECK(!decodeUsageRecord("garbage").has_value());
|
||||
CHECK(!decodeUsageRecord("rsusage1").has_value()); // truncated after magic
|
||||
CHECK(!decodeUsageRecord("rsusage2" "0:1:0").has_value()); // wrong magic version
|
||||
// Truncated mid-holds: claims 2 holds, carries 1.
|
||||
UsageRecord one = makeRecord("{G}", {UsageHold{"a", "p.wav"}});
|
||||
CHECK(!decodeUsageRecord("rsusage2" "0:0:1:01:0").has_value()); // wrong magic
|
||||
// A non-"0"/"1" unioned field is corruption -> reject whole. Hand-built wire:
|
||||
// magic + trackGuid "" + nonce "" + unioned "2" + count 0.
|
||||
CHECK(!decodeUsageRecord("rsusage1" "0:" "0:" "1:2" "1:0").has_value());
|
||||
// Truncated mid-holds: claims 2 holds, carries 1. The count field for one hold is
|
||||
// the "1:1" that FOLLOWS the unioned field "1:0" (nonce chosen digit-free so the
|
||||
// needle is unambiguous).
|
||||
UsageRecord one = makeRecord("{G}", "nonce", {UsageHold{"a", "p.wav"}});
|
||||
std::string wire = encodeUsageRecord(one);
|
||||
// Rewrite the count field "1:1" -> "1:2" (count is the 2nd field: len 1, value '1').
|
||||
const std::string needle = "1:1"; // count field for one hold
|
||||
const std::size_t pos = wire.find(needle, std::string("rsusage1").size() + 4);
|
||||
const std::string needle = "1:0" "1:1"; // unioned=0 then count=1
|
||||
const std::size_t pos = wire.find(needle);
|
||||
CHECK(pos != std::string::npos);
|
||||
wire[pos + 2] = '2';
|
||||
wire[pos + 5] = '2'; // count "1:1" -> "1:2"
|
||||
CHECK(!decodeUsageRecord(wire).has_value());
|
||||
// Trailing garbage after a complete record -> reject whole.
|
||||
CHECK(!decodeUsageRecord(encodeUsageRecord(one) + "x").has_value());
|
||||
@@ -94,102 +117,200 @@ static void testDecodeMalformed() {
|
||||
// --- publish plan --------------------------------------------------------------
|
||||
|
||||
static void testPlanFreshKey() {
|
||||
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "p.wav"}});
|
||||
const UsagePublishPlan plan = planUsagePublish(std::nullopt, "", mine);
|
||||
const UsageRecord mine = makeRecord("{T1}", "NA", {UsageHold{"a", "p.wav"}});
|
||||
const UsagePublishPlan plan = planUsagePublish(std::nullopt, mine);
|
||||
CHECK(!plan.remint);
|
||||
CHECK(!plan.skipWrite);
|
||||
CHECK(plan.wire == encodeUsageRecord(mine));
|
||||
auto back = decodeUsageRecord(plan.wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->ownerNonce == "NA");
|
||||
CHECK(!back->unioned); // sole known writer -> un-poisoned
|
||||
}
|
||||
|
||||
static void testPlanCleanReplaceAndSkip() {
|
||||
// The key holds exactly what this lifetime wrote -> clean replace; released holds drop.
|
||||
const UsageRecord prev = makeRecord("{T1}", {UsageHold{"a", "pa.wav"},
|
||||
UsageHold{"b", "pb.wav"}});
|
||||
// The key holds this incarnation's own un-poisoned write (nonce match, !unioned):
|
||||
// the sole-writer path. Clean replace; released holds drop.
|
||||
const UsageRecord prev = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"},
|
||||
UsageHold{"b", "pb.wav"}});
|
||||
const std::string prevWire = encodeUsageRecord(prev);
|
||||
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan plan = planUsagePublish(prevWire, prevWire, mine);
|
||||
const UsageRecord mine = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan plan = planUsagePublish(prevWire, mine);
|
||||
CHECK(!plan.remint);
|
||||
CHECK(!plan.skipWrite);
|
||||
auto back = decodeUsageRecord(plan.wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->holds.size() == 1); // 'b' genuinely released — NOT unioned back in
|
||||
CHECK(!back->unioned);
|
||||
|
||||
// Unchanged play-set -> byte-identical write -> skip (idle reload tick).
|
||||
const UsagePublishPlan idle = planUsagePublish(prevWire, prevWire, prev);
|
||||
const UsagePublishPlan idle = planUsagePublish(prevWire, prev);
|
||||
CHECK(idle.skipWrite);
|
||||
CHECK(!idle.remint);
|
||||
}
|
||||
|
||||
static void testPlanSameTrackUnion() {
|
||||
// First publish of a lifetime (lastPublished empty) over a same-track existing value:
|
||||
// my own last-session record OR a same-track copy-sibling — either way UNION, never
|
||||
// drop (the fail-safe direction; a sibling's holds must survive my write).
|
||||
const UsageRecord theirs = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
||||
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"b", "pb.wav"},
|
||||
UsageHold{"a", "pa.wav"}});
|
||||
// The review's 🔴#1 repro. Two same-track FX copies share a key and converge on
|
||||
// byte-identical hold sets; the OLD wire-equality discriminator let sibling A
|
||||
// clean-replace over B's still-held path. With the in-wire per-lifetime nonce +
|
||||
// sticky unioned poison, every same-track collision unions and NO write of A's can
|
||||
// ever drop B's holds — including A's SECOND write after it re-owns the key.
|
||||
static void testSiblingCollisionNeverDropsHolds() {
|
||||
// A (lifetime nonce NA) publishes {pa}.
|
||||
const UsageRecord aFirst = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan planA1 = planUsagePublish(std::nullopt, aFirst);
|
||||
CHECK(!planA1.remint && !planA1.skipWrite);
|
||||
|
||||
// B (lifetime nonce NB, same track, SAME hold set — the byte-identical
|
||||
// convergence) publishes {pa}: foreign nonce, same track -> UNION, and the
|
||||
// false->true poison flip is WRITTEN (never skipped), marking the key multi-writer.
|
||||
const UsageRecord bSame = makeRecord("{T1}", "NB", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan planB = planUsagePublish(planA1.wire, bSame);
|
||||
CHECK(!planB.remint);
|
||||
CHECK(!planB.skipWrite); // the poison flip must land in ext-state
|
||||
auto bBack = decodeUsageRecord(planB.wire);
|
||||
CHECK(bBack.has_value());
|
||||
CHECK(bBack->unioned);
|
||||
CHECK(holdsContainPath(bBack->holds, "pa.wav"));
|
||||
|
||||
// A changes its selection to {pc} (releases pa from ITS play-set — but B still
|
||||
// plays pa). Foreign nonce (NB) -> union: pa is RETAINED. Pre-fix this was the
|
||||
// clean-replace that dropped B's hold -> prune could delete B's playing file.
|
||||
const UsageRecord aSecond = makeRecord("{T1}", "NA", {UsageHold{"c", "pc.wav"}});
|
||||
const UsagePublishPlan planA2 = planUsagePublish(planB.wire, aSecond);
|
||||
CHECK(!planA2.remint);
|
||||
auto a2Back = decodeUsageRecord(planA2.wire);
|
||||
CHECK(a2Back.has_value());
|
||||
CHECK(holdsContainPath(a2Back->holds, "pa.wav")); // B's hold survives A's write
|
||||
CHECK(holdsContainPath(a2Back->holds, "pc.wav"));
|
||||
CHECK(a2Back->unioned);
|
||||
|
||||
// A writes AGAIN (selection {pd}) — now the key carries A's OWN nonce (NA). A bare
|
||||
// nonce discriminator would clean-replace here and drop pa one step late; the
|
||||
// sticky unioned poison forces union forever. pa STILL survives.
|
||||
const UsageRecord aThird = makeRecord("{T1}", "NA", {UsageHold{"d", "pd.wav"}});
|
||||
const UsagePublishPlan planA3 = planUsagePublish(planA2.wire, aThird);
|
||||
CHECK(!planA3.remint);
|
||||
auto a3Back = decodeUsageRecord(planA3.wire);
|
||||
CHECK(a3Back.has_value());
|
||||
CHECK(holdsContainPath(a3Back->holds, "pa.wav")); // the poison-flag guarantee
|
||||
CHECK(a3Back->unioned);
|
||||
}
|
||||
|
||||
static void testPlanUnionSkipOnlyWhenAlreadyPoisoned() {
|
||||
// Union over an ALREADY-unioned record that adds no holds -> skip (the write would
|
||||
// flip only the nonce — redundant churn; the protection is already in place).
|
||||
const UsageRecord poisoned = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}},
|
||||
/*unioned=*/true);
|
||||
const UsageRecord mineSubset = makeRecord("{T1}", "NB", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan plan =
|
||||
planUsagePublish(encodeUsageRecord(theirs), "", mine);
|
||||
planUsagePublish(encodeUsageRecord(poisoned), mineSubset);
|
||||
CHECK(!plan.remint);
|
||||
CHECK(plan.skipWrite);
|
||||
// But a union that ADDS a hold must write even when already poisoned.
|
||||
const UsageRecord mineNew = makeRecord("{T1}", "NB", {UsageHold{"b", "pb.wav"}});
|
||||
const UsagePublishPlan plan2 =
|
||||
planUsagePublish(encodeUsageRecord(poisoned), mineNew);
|
||||
CHECK(!plan2.skipWrite);
|
||||
auto back = decodeUsageRecord(plan2.wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->holds.size() == 2);
|
||||
CHECK(back->unioned);
|
||||
}
|
||||
|
||||
static void testPlanEmptyNonceNeverClaimsOwnership() {
|
||||
// A record written with an empty nonce (defensive: publisher failed to mint) can
|
||||
// never be claimed via empty==empty — the same-track path must UNION, not replace.
|
||||
const UsageRecord theirs = makeRecord("{T1}", "", {UsageHold{"a", "pa.wav"}});
|
||||
const UsageRecord mine = makeRecord("{T1}", "", {UsageHold{"b", "pb.wav"}});
|
||||
const UsagePublishPlan plan = planUsagePublish(encodeUsageRecord(theirs), mine);
|
||||
CHECK(!plan.remint);
|
||||
auto back = decodeUsageRecord(plan.wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->trackGuid == "{T1}");
|
||||
CHECK(back->holds.size() == 2); // a (existing-first) + b, de-duped
|
||||
CHECK(back->holds[0].sampleId == "a");
|
||||
CHECK(back->holds[1].sampleId == "b");
|
||||
CHECK(holdsContainPath(back->holds, "pa.wav")); // never dropped
|
||||
CHECK(back->unioned);
|
||||
}
|
||||
|
||||
static void testPlanCrossTrackRemint() {
|
||||
// The key holds a foreign record from ANOTHER track: this state was cloned there
|
||||
// (FX copy / track duplication) — take a fresh identity, leave theirs untouched.
|
||||
const UsageRecord theirs = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
||||
const UsageRecord mine = makeRecord("{T2}", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan plan =
|
||||
planUsagePublish(encodeUsageRecord(theirs), "", mine);
|
||||
const UsageRecord theirs = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}});
|
||||
const UsageRecord mine = makeRecord("{T2}", "NB", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan plan = planUsagePublish(encodeUsageRecord(theirs), mine);
|
||||
CHECK(plan.remint);
|
||||
CHECK(plan.wire == encodeUsageRecord(mine)); // written under the NEW key
|
||||
auto back = decodeUsageRecord(plan.wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(!back->unioned); // written under the NEW key — fresh, un-poisoned
|
||||
}
|
||||
|
||||
static void testPlanOwnRecordAfterTrackMove() {
|
||||
// My own un-poisoned record, but the instance moved tracks THIS lifetime (nonce
|
||||
// matches, track differs): still mine — clean replace with the new track guid, NOT
|
||||
// a remint (the key stays stable; no record proliferation on a track move).
|
||||
const UsageRecord prev = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}});
|
||||
const UsageRecord mine = makeRecord("{T2}", "NA", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan plan = planUsagePublish(encodeUsageRecord(prev), mine);
|
||||
CHECK(!plan.remint);
|
||||
auto back = decodeUsageRecord(plan.wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->trackGuid == "{T2}");
|
||||
}
|
||||
|
||||
static void testPlanUndecodableExisting() {
|
||||
// An undecodable existing value protects nothing — overwrite with mine.
|
||||
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan plan = planUsagePublish(std::string("corrupt"), "", mine);
|
||||
// An undecodable existing value under MY key is corruption — overwrite with mine
|
||||
// (the self-heal; the prune side independently aborts while it is unreadable).
|
||||
const UsageRecord mine = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan plan = planUsagePublish(std::string("corrupt"), mine);
|
||||
CHECK(!plan.remint);
|
||||
CHECK(plan.wire == encodeUsageRecord(mine));
|
||||
CHECK(!plan.skipWrite);
|
||||
auto back = decodeUsageRecord(plan.wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->holds.size() == 1);
|
||||
}
|
||||
|
||||
// --- liveness fold ---------------------------------------------------------------
|
||||
|
||||
static void testHeldPathsLiveness() {
|
||||
const std::vector<UsageRecord> records = {
|
||||
makeRecord("{LIVE}", {UsageHold{"a", "pa.wav"}}),
|
||||
makeRecord("{DEAD}", {UsageHold{"b", "pb.wav"}}), // deleted track/instance
|
||||
makeRecord("", {UsageHold{"c", "pc.wav"}}), // no track context
|
||||
makeRecord("{LIVE}", "N1", {UsageHold{"a", "pa.wav"}}),
|
||||
makeRecord("{DEAD}", "N2", {UsageHold{"b", "pb.wav"}}), // deleted track
|
||||
makeRecord("", "N3", {UsageHold{"c", "pc.wav"}}), // no track context
|
||||
};
|
||||
const std::unordered_set<std::string> live = {"{LIVE}"};
|
||||
|
||||
// Live-track record counts; dead-track record is EXCLUDED (no stale false-protect);
|
||||
// empty-guid record counts while ANY instance lives (fail-safe fallback).
|
||||
// Live-track record counts; dead-track record is EXCLUDED (stale-record cleanup —
|
||||
// possible ONLY because at least one instance was positively identified, so the
|
||||
// matcher demonstrably works in this project); empty-guid record counts while ANY
|
||||
// instance lives (fail-safe fallback).
|
||||
const std::vector<std::string> withAny = usageHeldPaths(records, live, true);
|
||||
CHECK(withAny.size() == 2);
|
||||
CHECK(withAny[0] == "pa.wav");
|
||||
CHECK(withAny[1] == "pc.wav");
|
||||
}
|
||||
|
||||
// No instance anywhere -> empty-guid fallback closes too; only live-track survives.
|
||||
const std::vector<std::string> noAny = usageHeldPaths(records, live, false);
|
||||
CHECK(noAny.size() == 1);
|
||||
CHECK(noAny[0] == "pa.wav");
|
||||
|
||||
// Zero live instances at all -> nothing protected (a project whose instances were
|
||||
// all deleted cannot be permanently blocked by leftover records).
|
||||
const std::vector<std::string> none =
|
||||
// The review's 🔴#2 repro: records exist but ZERO instances were identified live
|
||||
// (either every instance was genuinely deleted, or — indistinguishable — the identity
|
||||
// matcher failed on every FX). The old fold dropped every record -> all held captures
|
||||
// became prunable. The fail-safe net protects ALL records' paths instead.
|
||||
static void testZeroIdentifiedProtectsAll() {
|
||||
const std::vector<UsageRecord> records = {
|
||||
makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}),
|
||||
makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}}),
|
||||
makeRecord("", "N3", {UsageHold{"c", "pc.wav"}}),
|
||||
};
|
||||
const std::vector<std::string> all =
|
||||
usageHeldPaths(records, std::unordered_set<std::string>{}, false);
|
||||
CHECK(none.empty());
|
||||
CHECK(all.size() == 3); // EVERY path protected — never zero
|
||||
CHECK(all[0] == "pa.wav");
|
||||
CHECK(all[1] == "pb.wav");
|
||||
CHECK(all[2] == "pc.wav");
|
||||
|
||||
// No records at all -> nothing to protect (the common no-instances case).
|
||||
CHECK(usageHeldPaths({}, std::unordered_set<std::string>{}, false).empty());
|
||||
}
|
||||
|
||||
static void testHeldPathsDedupAndEmptyPathSkip() {
|
||||
const std::vector<UsageRecord> records = {
|
||||
makeRecord("{T}", {UsageHold{"a", "shared.wav"}, UsageHold{"x", ""}}),
|
||||
makeRecord("{T}", {UsageHold{"b", "shared.wav"}, UsageHold{"c", "own.wav"}}),
|
||||
makeRecord("{T}", "N1", {UsageHold{"a", "shared.wav"}, UsageHold{"x", ""}}),
|
||||
makeRecord("{T}", "N2", {UsageHold{"b", "shared.wav"}, UsageHold{"c", "own.wav"}}),
|
||||
};
|
||||
const std::unordered_set<std::string> live = {"{T}"};
|
||||
const std::vector<std::string> paths = usageHeldPaths(records, live, true);
|
||||
@@ -198,6 +319,92 @@ static void testHeldPathsDedupAndEmptyPathSkip() {
|
||||
CHECK(paths[1] == "own.wav");
|
||||
}
|
||||
|
||||
// A take-FX-hosted instance: the shell attributes it to the ITEM'S OWNING TRACK (the
|
||||
// same guid the VST-side getReaperParent(1) publishes), so at the pure layer its record
|
||||
// folds exactly like a track-FX one. This is the pure half of the take-FX guarantee;
|
||||
// the enumeration itself (TakeFX_* walk, fx_ident + original_name) is shell code.
|
||||
static void testTakeFxAttributedRecordIsProtected() {
|
||||
const std::vector<UsageRecord> records = {
|
||||
makeRecord("{ITEM-TRACK}", "N1", {UsageHold{"a", "take-held.wav"}}),
|
||||
};
|
||||
const std::unordered_set<std::string> live = {"{ITEM-TRACK}"}; // set via item scan
|
||||
const std::vector<std::string> paths = usageHeldPaths(records, live, true);
|
||||
CHECK(paths.size() == 1);
|
||||
CHECK(paths[0] == "take-held.wav");
|
||||
}
|
||||
|
||||
// --- the unreadable-record abort (foldUsageRecords) --------------------------------
|
||||
// A present-but-unreadable/undecodable rsusage_* record must ABORT the prune (halt,
|
||||
// delete nothing) — silently reduced protection is the delete direction.
|
||||
|
||||
static void testUnreadableRecordAbortsPrune() {
|
||||
std::vector<std::optional<UsageRecord>> decoded;
|
||||
decoded.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
|
||||
decoded.push_back(std::nullopt); // one unreadable record among readable ones
|
||||
const UsageFoldResult fold =
|
||||
foldUsageRecords(decoded, std::unordered_set<std::string>{"{T1}"}, true);
|
||||
CHECK(fold.abortPrune);
|
||||
|
||||
// All readable -> no abort, normal liveness fold.
|
||||
std::vector<std::optional<UsageRecord>> ok;
|
||||
ok.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
|
||||
const UsageFoldResult okFold =
|
||||
foldUsageRecords(ok, std::unordered_set<std::string>{"{T1}"}, true);
|
||||
CHECK(!okFold.abortPrune);
|
||||
CHECK(okFold.heldPaths.size() == 1);
|
||||
CHECK(okFold.heldPaths[0] == "pa.wav");
|
||||
|
||||
// Empty input (no records enumerated) -> empty, no abort.
|
||||
const UsageFoldResult empty =
|
||||
foldUsageRecords({}, std::unordered_set<std::string>{}, false);
|
||||
CHECK(!empty.abortPrune);
|
||||
CHECK(empty.heldPaths.empty());
|
||||
|
||||
// Readable records + zero identified -> the protect-all net applies through the
|
||||
// fold too (belt and braces with the abort).
|
||||
std::vector<std::optional<UsageRecord>> unmatched;
|
||||
unmatched.push_back(makeRecord("{T9}", "N1", {UsageHold{"a", "pa.wav"}}));
|
||||
const UsageFoldResult net =
|
||||
foldUsageRecords(unmatched, std::unordered_set<std::string>{}, false);
|
||||
CHECK(!net.abortPrune);
|
||||
CHECK(net.heldPaths.size() == 1);
|
||||
}
|
||||
|
||||
// --- the identity matcher ----------------------------------------------------------
|
||||
// The common-case shapes: REAPER's fx_ident carries the .vst3 MODULE PATH (matched by
|
||||
// the output-name needle "REASAMPLER_9000" — the display name, space-separated, can
|
||||
// never match it); original_name carries the display name. UID hex matches when the
|
||||
// rendering embeds it. All substring, case-insensitive.
|
||||
|
||||
static void testIdentityMatcher() {
|
||||
const std::string uid = "ABCD1234ABCD1234ABCD1234ABCD1234";
|
||||
const std::string name = "REASAMPLER 9000";
|
||||
const std::string output = "REASAMPLER_9000";
|
||||
|
||||
// The module-path fx_ident shape: ONLY the output-name needle can catch this (the
|
||||
// review's 🔴#2b — the display name alone silently failed the common case).
|
||||
const std::string modulePath =
|
||||
"C:\\Program Files\\Common Files\\VST3\\reasampler_9000.vst3";
|
||||
CHECK(identityMatches(modulePath, uid, name, output));
|
||||
CHECK(!identityMatches(modulePath, uid, name, "")); // display name can't match it
|
||||
|
||||
// The display-name shape (original_name / renamed renderings).
|
||||
CHECK(identityMatches("VST3: ReaSampler 9000", uid, name, output));
|
||||
|
||||
// The UID-hex shape (case-insensitive).
|
||||
CHECK(identityMatches("vst3<abcd1234abcd1234abcd1234abcd1234>", uid, name, output));
|
||||
|
||||
// Beta over-protect (deliberate): stable needles are substrings of beta renderings
|
||||
// — a stable extension protects beta instances' holds too (wider set only).
|
||||
CHECK(identityMatches("...\\reasampler_9000_beta.vst3", uid, name, output));
|
||||
CHECK(identityMatches("ReaSampler 9000 beta", uid, name, output));
|
||||
|
||||
// Non-matches stay non-matches.
|
||||
CHECK(!identityMatches("", uid, name, output));
|
||||
CHECK(!identityMatches("ReaComp", uid, name, output));
|
||||
CHECK(!identityMatches("some_other_sampler.vst3", uid, name, output));
|
||||
}
|
||||
|
||||
// --- the composed prune-protection proof -----------------------------------------
|
||||
// The definition-of-done property at the pure layer: a capture held by a live instance
|
||||
// lands in the referenced union, and pruneOrphans can NEVER emit it — even when the
|
||||
@@ -214,7 +421,7 @@ static void testInstanceHoldMakesPathUnprunable() {
|
||||
|
||||
// A live instance holds held.wav -> the union protects it; orphan.wav still reclaims.
|
||||
const std::vector<UsageRecord> records = {
|
||||
makeRecord("{T}", {UsageHold{"id-held", "held.wav"}})};
|
||||
makeRecord("{T}", "N1", {UsageHold{"id-held", "held.wav"}})};
|
||||
const std::unordered_set<std::string> live = {"{T}"};
|
||||
const std::vector<std::string> referenced =
|
||||
mergeReferenced(bankRefs, usageHeldPaths(records, live, true));
|
||||
@@ -222,10 +429,23 @@ static void testInstanceHoldMakesPathUnprunable() {
|
||||
CHECK(orphans.size() == 1);
|
||||
CHECK(orphans[0] == "orphan.wav");
|
||||
|
||||
// The instance (and its track) deleted -> the record no longer counts -> held.wav
|
||||
// is reclaimable again (no permanent stale-key block).
|
||||
const std::vector<std::string> refsAfterDelete = mergeReferenced(
|
||||
// Zero instances identified anywhere -> the protect-all net keeps held.wav
|
||||
// un-prunable THROUGH the composed pipeline too (matcher failure must never
|
||||
// resolve toward delete; the accepted residual is that leftover records keep
|
||||
// protecting until an instance is identified again).
|
||||
const std::vector<std::string> refsNoneIdentified = mergeReferenced(
|
||||
bankRefs, usageHeldPaths(records, std::unordered_set<std::string>{}, false));
|
||||
const std::vector<std::string> orphansNone =
|
||||
pruneOrphans(present, refsNoneIdentified, owned);
|
||||
CHECK(orphansNone.size() == 1);
|
||||
CHECK(orphansNone[0] == "orphan.wav");
|
||||
|
||||
// Stale-record cleanup still works when the matcher is demonstrably alive: another
|
||||
// instance is identified on a different track, the record's own track is gone ->
|
||||
// the record no longer counts -> held.wav is reclaimable again.
|
||||
const std::vector<std::string> refsAfterDelete = mergeReferenced(
|
||||
bankRefs,
|
||||
usageHeldPaths(records, std::unordered_set<std::string>{"{OTHER}"}, true));
|
||||
CHECK(pruneOrphans(present, refsAfterDelete, owned).size() == 2);
|
||||
}
|
||||
|
||||
@@ -247,11 +467,19 @@ int main() {
|
||||
testDecodeMalformed();
|
||||
testPlanFreshKey();
|
||||
testPlanCleanReplaceAndSkip();
|
||||
testPlanSameTrackUnion();
|
||||
testSiblingCollisionNeverDropsHolds();
|
||||
testPlanUnionSkipOnlyWhenAlreadyPoisoned();
|
||||
testPlanEmptyNonceNeverClaimsOwnership();
|
||||
testPlanCrossTrackRemint();
|
||||
testPlanOwnRecordAfterTrackMove();
|
||||
testPlanUndecodableExisting();
|
||||
testHeldPathsLiveness();
|
||||
testZeroIdentifiedProtectsAll();
|
||||
testHeldPathsDedupAndEmptyPathSkip();
|
||||
testTakeFxAttributedRecordIsProtected();
|
||||
testUnreadableRecordAbortsPrune();
|
||||
testIdentityMatcher();
|
||||
|
||||
testInstanceHoldMakesPathUnprunable();
|
||||
testMergeReferencedOrderAndDedup();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user