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:
+4
-2
@@ -339,10 +339,12 @@ target_include_directories(assignment_request PUBLIC src)
|
|||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2j'') Pure sample_usage library — NO REAPER, NO SWELL, NO VST3. The pS-usage seam:
|
# 2j'') Pure sample_usage library — NO REAPER, NO SWELL, NO VST3. The pS-usage seam:
|
||||||
# the per-instance usage record the INSTRUMENT writes ("usage_<guid>" ext-state,
|
# the per-instance usage record the INSTRUMENT writes ("rsusage_<guid>" ext-state,
|
||||||
# the one sanctioned VST-side write) and the EXTENSION reads at prune time. Owns
|
# the one sanctioned VST-side write) and the EXTENSION reads at prune time. Owns
|
||||||
# the wire round-trip, the publish plan (copy-collision fail-safe resolution),
|
# the wire round-trip, the publish plan (copy-collision fail-safe resolution),
|
||||||
# and the liveness fold (which records count against the live FX enumeration).
|
# the liveness fold (which records count against the live FX enumeration,
|
||||||
|
# protect-all when zero identified, abort on unreadable), and the FX identity
|
||||||
|
# matcher.
|
||||||
# Linked by BOTH artifacts — the mirror of assignment_request, reversed direction.
|
# Linked by BOTH artifacts — the mirror of assignment_request, reversed direction.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
add_library(sample_usage STATIC src/sample_usage.cpp)
|
add_library(sample_usage STATIC src/sample_usage.cpp)
|
||||||
|
|||||||
@@ -821,6 +821,18 @@ void doBankRemoveSelected() {
|
|||||||
void doBankPruneFolder() {
|
void doBankPruneFolder() {
|
||||||
const PruneReport report = g_session->pruneDryRun();
|
const PruneReport report = g_session->pruneDryRun();
|
||||||
|
|
||||||
|
// pS-usage FAIL-SAFE: a present instance-usage record could not be read — the
|
||||||
|
// protected set is unknowable, so the prune HALTS outright (deletes nothing) rather
|
||||||
|
// than proceed with degraded protection. Distinct from "no orphans": the user must
|
||||||
|
// know the prune refused to run and why.
|
||||||
|
if (report.abortedUnreadableUsage) {
|
||||||
|
ShowConsoleMsg(
|
||||||
|
"ReaSampler prune: ABORTED -- an instance usage record could not be read.\n"
|
||||||
|
"Nothing was deleted. Re-opening the project usually clears this (instances "
|
||||||
|
"republish their usage records on load).\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (report.count == 0) {
|
if (report.count == 0) {
|
||||||
ShowConsoleMsg("ReaSampler prune: no orphaned files to reclaim.\n");
|
ShowConsoleMsg("ReaSampler prune: no orphaned files to reclaim.\n");
|
||||||
return;
|
return;
|
||||||
|
|||||||
+9
-6
@@ -70,17 +70,20 @@ inline constexpr const char* kProjExtBankGenKey = "bank_generation";
|
|||||||
inline constexpr const char* kProjExtAssignKey = "assign_request";
|
inline constexpr const char* kProjExtAssignKey = "assign_request";
|
||||||
|
|
||||||
// The pS-usage PER-INSTANCE USAGE-RECORD key prefix. The INSTRUMENT writes one key per
|
// The pS-usage PER-INSTANCE USAGE-RECORD key prefix. The INSTRUMENT writes one key per
|
||||||
// instance — "usage_<instanceGuid>" — carrying the sample_usage wire record of every
|
// instance — "rsusage_<instanceGuid>" — carrying the sample_usage wire record of every
|
||||||
// capture that instance holds; the EXTENSION enumerates the prefix at prune-scan time
|
// capture that instance holds; the EXTENSION enumerates the prefix at prune-scan time
|
||||||
// and folds live instances' holds into the prune's `referenced` set so a held capture
|
// and folds live instances' holds into the prune's `referenced` set so a held capture
|
||||||
// can never be pruned. This is the ONE sanctioned instrument-side ext-state write
|
// can never be pruned. This is the ONE sanctioned instrument-side ext-state write
|
||||||
// (Daniel's ruling — the VST publishes its OWN usage; it never mutates banks/view/
|
// (Daniel's ruling — the VST publishes its OWN usage; it never mutates banks/view/
|
||||||
// tail/assign, and the bridge's write entry point structurally accepts only this
|
// tail/assign, and the bridge's write entry point structurally accepts only this
|
||||||
// prefix). WIRE-SHARED in the write->read direction the other keys reverse. FOREVER-
|
// prefix). WIRE-SHARED in the write->read direction the other keys reverse. The "rs"
|
||||||
// STABLE once shipped: changing the prefix strands every saved project's usage records
|
// qualifier is deliberate: a future key that happens to start with "usage_" must never
|
||||||
// (prune falls back to bank-references-only until instances republish — graceful, but
|
// be swept into the FX-liveness fold (whose abort-on-unreadable rule would then halt
|
||||||
// the instance-hold protection lapses for stale-saved projects).
|
// every prune), so the prefix is namespaced like the wire magics (rsusage1/rsassign1).
|
||||||
inline constexpr const char* kProjExtUsageKeyPrefix = "usage_";
|
// FOREVER-STABLE once shipped: changing the prefix strands every saved project's usage
|
||||||
|
// records (prune falls back to bank-references-only until instances republish —
|
||||||
|
// graceful, but the instance-hold protection lapses for stale-saved projects).
|
||||||
|
inline constexpr const char* kProjExtUsageKeyPrefix = "rsusage_";
|
||||||
|
|
||||||
// The full per-instance usage key for a minted instance GUID (the one composition
|
// The full per-instance usage key for a minted instance GUID (the one composition
|
||||||
// point, shared by the instrument's writer and the extension's enumerator).
|
// point, shared by the instrument's writer and the extension's enumerator).
|
||||||
|
|||||||
+31
-5
@@ -306,10 +306,17 @@ constexpr std::size_t kPruneListDisplayCap = 64;
|
|||||||
// * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration
|
// * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration
|
||||||
// order, untruncated. The pure core decides; this only supplies inputs.
|
// order, untruncated. The pure core decides; this only supplies inputs.
|
||||||
// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd).
|
// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd).
|
||||||
|
// * abortedUnreadableUsage — true iff a present rsusage_* instance-usage record could
|
||||||
|
// not be read/decoded (pS-usage fail-safe): `orphans` is left EMPTY —
|
||||||
|
// the prune must halt rather than proceed with degraded protection.
|
||||||
|
// An empty orphan set is itself the delete-side guarantee (every
|
||||||
|
// consumer of this scan deletes at most `orphans ∩ ...`), the flag is
|
||||||
|
// what lets the action TELL the user instead of claiming "no orphans".
|
||||||
struct PruneScan {
|
struct PruneScan {
|
||||||
std::string bankDirAbs;
|
std::string bankDirAbs;
|
||||||
std::vector<std::string> orphans;
|
std::vector<std::string> orphans;
|
||||||
std::unordered_map<std::string, std::uint64_t> sizeByRel;
|
std::unordered_map<std::string, std::uint64_t> sizeByRel;
|
||||||
|
bool abortedUnreadableUsage = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
|
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
|
||||||
@@ -362,14 +369,24 @@ PruneScan scanPruneOrphans(const BankBook& book, const OwnedFileManifest& owned)
|
|||||||
// referencedPaths() unions across the whole book (pool included); owned().paths() is
|
// referencedPaths() unions across the whole book (pool included); owned().paths() is
|
||||||
// the manifest set. pS-usage: the referenced set additionally unions every LIVE
|
// the manifest set. pS-usage: the referenced set additionally unions every LIVE
|
||||||
// ReaSampler 9000 instance's held captures (usage_scan reads the per-instance
|
// ReaSampler 9000 instance's held captures (usage_scan reads the per-instance
|
||||||
// usage_* records + the live FX enumeration; sample_usage decides liveness) — a
|
// rsusage_* records + the live FX enumeration; sample_usage decides liveness,
|
||||||
// capture any live instance holds can NEVER be an orphan, even when its bank entry
|
// including the protect-all net when zero instances were identified) — a capture
|
||||||
// was deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY,
|
// any live instance holds can NEVER be an orphan, even when its bank entry was
|
||||||
|
// deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY,
|
||||||
// preserving this scan's no-write contract. This shell only enumerates, resolves,
|
// preserving this scan's no-write contract. This shell only enumerates, resolves,
|
||||||
// and stats.
|
// and stats.
|
||||||
scan.bankDirAbs = bankDir;
|
scan.bankDirAbs = bankDir;
|
||||||
|
const UsageScanResult usage = liveInstanceHeldPaths(proj);
|
||||||
|
if (usage.abortPrune) {
|
||||||
|
// FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, so the
|
||||||
|
// protected set is unknowable. Compute NO orphans — every downstream consumer
|
||||||
|
// (dry-run report, confirm set, fresh-recompute delete plan) then deletes
|
||||||
|
// nothing. The flag surfaces the reason to the action's console message.
|
||||||
|
scan.abortedUnreadableUsage = true;
|
||||||
|
return scan;
|
||||||
|
}
|
||||||
scan.orphans = pruneOrphans(
|
scan.orphans = pruneOrphans(
|
||||||
present, mergeReferenced(book.referencedPaths(), liveInstanceHeldPaths(proj)),
|
present, mergeReferenced(book.referencedPaths(), usage.heldPaths),
|
||||||
owned.paths());
|
owned.paths());
|
||||||
return scan;
|
return scan;
|
||||||
}
|
}
|
||||||
@@ -380,7 +397,13 @@ PruneReport ReaSamplerSession::pruneDryRun() const {
|
|||||||
const PruneScan scan = scanPruneOrphans(book_, owned_);
|
const PruneScan scan = scanPruneOrphans(book_, owned_);
|
||||||
// buildPruneReport tallies count / byte-sum / display-truncation — no report logic
|
// buildPruneReport tallies count / byte-sum / display-truncation — no report logic
|
||||||
// re-implemented here. An empty scan (no project / no folder) yields a zero report.
|
// re-implemented here. An empty scan (no project / no folder) yields a zero report.
|
||||||
return buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
|
PruneReport report =
|
||||||
|
buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
|
||||||
|
// pS-usage fail-safe: surface the unreadable-record abort so the action halts with
|
||||||
|
// an explicit message instead of reporting "no orphaned files" (the count IS zero —
|
||||||
|
// the scan computed nothing — but the user must know the prune refused to run).
|
||||||
|
report.abortedUnreadableUsage = scan.abortedUnreadableUsage;
|
||||||
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
|
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
|
||||||
@@ -466,6 +489,9 @@ PruneDeletionResult ReaSamplerSession::pruneReclaim(
|
|||||||
// newly-appeared orphan not in `confirmed` is never swept without its own confirm.
|
// newly-appeared orphan not in `confirmed` is never swept without its own confirm.
|
||||||
// Because freshOrphans is itself a pure-core output, the plan can contain NO referenced
|
// Because freshOrphans is itself a pure-core output, the plan can contain NO referenced
|
||||||
// and NO hand-dropped file — the R-C/R-D safety survives the recompute.
|
// and NO hand-dropped file — the R-C/R-D safety survives the recompute.
|
||||||
|
// pS-usage: if THIS fresh scan hits an unreadable rsusage_* record it aborts with an
|
||||||
|
// EMPTY orphan set, so the plan below intersects to empty and nothing is deleted —
|
||||||
|
// the fail-safe holds even in the confirm→delete window, with no extra branch here.
|
||||||
const PruneScan scan = scanPruneOrphans(book_, owned_);
|
const PruneScan scan = scanPruneOrphans(book_, owned_);
|
||||||
if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing
|
if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -186,9 +186,11 @@ public:
|
|||||||
// own convention (bankRelativeForName — byte-identical to the capture path's spelling),
|
// own convention (bankRelativeForName — byte-identical to the capture path's spelling),
|
||||||
// and feeds the R1 pure core with (present, referenced, owned().paths()) where
|
// and feeds the R1 pure core with (present, referenced, owned().paths()) where
|
||||||
// `referenced` = book().referencedPaths() ∪ every LIVE ReaSampler 9000 instance's
|
// `referenced` = book().referencedPaths() ∪ every LIVE ReaSampler 9000 instance's
|
||||||
// held captures (pS-usage: usage_scan reads the per-instance usage_* ext-state
|
// held captures (pS-usage: usage_scan reads the per-instance rsusage_* ext-state
|
||||||
// records + the live FX enumeration; sample_usage decides liveness) — a capture any
|
// records + the live FX enumeration; sample_usage decides liveness) — a capture any
|
||||||
// live instance holds can never be an orphan, so the prune can never delete it.
|
// live instance holds can never be an orphan, so the prune can never delete it.
|
||||||
|
// FAIL-SAFE: a present-but-unreadable usage record sets the report's
|
||||||
|
// abortedUnreadableUsage flag with an EMPTY orphan set — the prune action halts.
|
||||||
// Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file
|
// Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file
|
||||||
// list. The decision stays in the pure core — this method only enumerates, resolves,
|
// list. The decision stays in the pure core — this method only enumerates, resolves,
|
||||||
// and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no
|
// and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no
|
||||||
|
|||||||
@@ -64,11 +64,18 @@ namespace reasampler {
|
|||||||
// `truncated` says whether the list was clipped.
|
// `truncated` says whether the list was clipped.
|
||||||
// * truncated — true iff `orphans` holds fewer than `count` entries (a large set was
|
// * truncated — true iff `orphans` holds fewer than `count` entries (a large set was
|
||||||
// clipped for display); false when the list is complete.
|
// clipped for display); false when the list is complete.
|
||||||
|
// * abortedUnreadableUsage — true iff the scan found a present-but-unreadable
|
||||||
|
// rsusage_* instance-usage record (pS-usage fail-safe): the orphan
|
||||||
|
// computation was NOT performed (count 0, empty list) and the prune
|
||||||
|
// must HALT — deleting with degraded protection is the data-loss
|
||||||
|
// direction. Set by the session's scan shell, never by
|
||||||
|
// buildPruneReport (which stays a pure tally).
|
||||||
struct PruneReport {
|
struct PruneReport {
|
||||||
std::size_t count = 0;
|
std::size_t count = 0;
|
||||||
std::uint64_t totalBytes = 0;
|
std::uint64_t totalBytes = 0;
|
||||||
std::vector<std::string> orphans;
|
std::vector<std::string> orphans;
|
||||||
bool truncated = false;
|
bool truncated = false;
|
||||||
|
bool abortedUnreadableUsage = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The outcome of an actual prune DELETION (Phase R, Wave 3 — R3). The prune shell fills
|
// The outcome of an actual prune DELETION (Phase R, Wave 3 — R3). The prune shell fills
|
||||||
|
|||||||
+127
-35
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "sample_usage.h"
|
#include "sample_usage.h"
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
|
||||||
@@ -92,6 +93,8 @@ private:
|
|||||||
std::string encodeUsageRecord(const UsageRecord& rec) {
|
std::string encodeUsageRecord(const UsageRecord& rec) {
|
||||||
std::string out = kMagic;
|
std::string out = kMagic;
|
||||||
putField(out, rec.trackGuid);
|
putField(out, rec.trackGuid);
|
||||||
|
putField(out, rec.ownerNonce);
|
||||||
|
putField(out, rec.unioned ? "1" : "0");
|
||||||
putField(out, std::to_string(rec.holds.size()));
|
putField(out, std::to_string(rec.holds.size()));
|
||||||
for (const UsageHold& h : rec.holds) {
|
for (const UsageHold& h : rec.holds) {
|
||||||
putField(out, h.sampleId);
|
putField(out, h.sampleId);
|
||||||
@@ -105,6 +108,12 @@ std::optional<UsageRecord> decodeUsageRecord(const std::string& wire) {
|
|||||||
if (!c.literal(kMagic)) return std::nullopt;
|
if (!c.literal(kMagic)) return std::nullopt;
|
||||||
UsageRecord rec;
|
UsageRecord rec;
|
||||||
if (!c.field(rec.trackGuid)) return std::nullopt;
|
if (!c.field(rec.trackGuid)) return std::nullopt;
|
||||||
|
if (!c.field(rec.ownerNonce)) return std::nullopt;
|
||||||
|
std::string unionedField;
|
||||||
|
if (!c.field(unionedField)) return std::nullopt;
|
||||||
|
if (unionedField == "1") rec.unioned = true;
|
||||||
|
else if (unionedField == "0") rec.unioned = false;
|
||||||
|
else return std::nullopt; // anything else is corruption -> reject whole
|
||||||
std::size_t count = 0;
|
std::size_t count = 0;
|
||||||
if (!c.fieldCount(count)) return std::nullopt;
|
if (!c.fieldCount(count)) return std::nullopt;
|
||||||
// Each hold needs at least 4 wire bytes ("0:0:"), so a count past wire.size()/4 is
|
// Each hold needs at least 4 wire bytes ("0:0:"), so a count past wire.size()/4 is
|
||||||
@@ -122,44 +131,73 @@ std::optional<UsageRecord> decodeUsageRecord(const std::string& wire) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
UsagePublishPlan planUsagePublish(const std::optional<std::string>& existing,
|
UsagePublishPlan planUsagePublish(const std::optional<std::string>& existing,
|
||||||
const std::string& lastPublishedThisLifetime,
|
|
||||||
const UsageRecord& mine) {
|
const UsageRecord& mine) {
|
||||||
UsagePublishPlan plan;
|
UsagePublishPlan plan;
|
||||||
plan.wire = encodeUsageRecord(mine);
|
// The written form of "just mine": mine's identity + holds, unioned=false (the plan
|
||||||
|
// computes the flag; a sole-writer record is un-poisoned).
|
||||||
|
UsageRecord cleanMine = mine;
|
||||||
|
cleanMine.unioned = false;
|
||||||
|
plan.wire = encodeUsageRecord(cleanMine);
|
||||||
|
|
||||||
if (!existing || existing->empty()) {
|
if (!existing || existing->empty()) {
|
||||||
// Fresh key — write mine.
|
// Fresh key — write mine.
|
||||||
} else if (!lastPublishedThisLifetime.empty() &&
|
return plan;
|
||||||
*existing == lastPublishedThisLifetime) {
|
|
||||||
// The key holds exactly what THIS instance wrote this lifetime: the normal
|
|
||||||
// single-owner path. Clean replace (released holds genuinely drop).
|
|
||||||
if (plan.wire == lastPublishedThisLifetime) plan.skipWrite = true;
|
|
||||||
} else {
|
|
||||||
const std::optional<UsageRecord> theirs = decodeUsageRecord(*existing);
|
|
||||||
if (!theirs) {
|
|
||||||
// Undecodable existing value — overwrite with mine (it protects nothing).
|
|
||||||
} else if (theirs->trackGuid == mine.trackGuid) {
|
|
||||||
// Foreign value from MY OWN track: my own persisted record from the last
|
|
||||||
// session, or a same-track copy-sibling. Either way no hold in it may be
|
|
||||||
// dropped by me — union, existing-first, de-duped. Over-protects (fail-safe)
|
|
||||||
// until the next clean replace.
|
|
||||||
UsageRecord merged;
|
|
||||||
merged.trackGuid = mine.trackGuid;
|
|
||||||
merged.holds = theirs->holds;
|
|
||||||
for (const UsageHold& h : mine.holds) {
|
|
||||||
bool dup = false;
|
|
||||||
for (const UsageHold& e : merged.holds) {
|
|
||||||
if (e == h) { dup = true; break; }
|
|
||||||
}
|
|
||||||
if (!dup) merged.holds.push_back(h);
|
|
||||||
}
|
|
||||||
plan.wire = encodeUsageRecord(merged);
|
|
||||||
} else {
|
|
||||||
// Foreign value from ANOTHER track: this instance is a cross-track copy (or
|
|
||||||
// was moved). Take a fresh identity; never overwrite the other's record.
|
|
||||||
plan.remint = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const std::optional<UsageRecord> theirs = decodeUsageRecord(*existing);
|
||||||
|
if (!theirs) {
|
||||||
|
// Undecodable existing value under MY OWN key: a sibling sharing this key
|
||||||
|
// (copy) always writes decodable records, so this is corruption. Overwrite
|
||||||
|
// with mine — the self-heal restores correct protection for my holds; the
|
||||||
|
// prune side independently ABORTS while an unreadable record is present
|
||||||
|
// (foldUsageRecords), so the corrupt window can never cause a delete.
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool nonceMatch =
|
||||||
|
!mine.ownerNonce.empty() && theirs->ownerNonce == mine.ownerNonce;
|
||||||
|
|
||||||
|
if (nonceMatch && !theirs->unioned) {
|
||||||
|
// Exactly THIS incarnation wrote the key (the per-lifetime nonce is the exact
|
||||||
|
// ownership proof — a same-track sibling's byte-identical hold set can NOT pass
|
||||||
|
// this test, its nonce differs) AND no other writer has ever unioned into it,
|
||||||
|
// so the content is provably all mine. Clean replace: released holds drop.
|
||||||
|
if (plan.wire == *existing) plan.skipWrite = true; // idle reload tick
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theirs->trackGuid == mine.trackGuid || (nonceMatch && theirs->unioned)) {
|
||||||
|
// A foreign writer on MY OWN track (a same-track copy-sibling, or my own
|
||||||
|
// last-session record — indistinguishable by construction), or a record I
|
||||||
|
// wrote last but that carries unioned holds from an earlier multi-writer
|
||||||
|
// merge. Either way no hold in it may be dropped by me — union, existing-
|
||||||
|
// first, de-duped, and the record is (or stays) POISONED unioned=true so no
|
||||||
|
// future nonce-matching write can clean-replace a sibling's holds away.
|
||||||
|
UsageRecord merged;
|
||||||
|
merged.trackGuid = mine.trackGuid;
|
||||||
|
merged.ownerNonce = mine.ownerNonce;
|
||||||
|
merged.unioned = true;
|
||||||
|
merged.holds = theirs->holds;
|
||||||
|
for (const UsageHold& h : mine.holds) {
|
||||||
|
bool dup = false;
|
||||||
|
for (const UsageHold& e : merged.holds) {
|
||||||
|
if (e == h) { dup = true; break; }
|
||||||
|
}
|
||||||
|
if (!dup) merged.holds.push_back(h);
|
||||||
|
}
|
||||||
|
if (theirs->unioned && merged.holds == theirs->holds) {
|
||||||
|
// Already poisoned and the union adds nothing — the write would flip only
|
||||||
|
// the ownerNonce. Skip the redundant ext-state churn. (A false->true
|
||||||
|
// unioned flip is NEVER skipped: it is the poison that protects the other
|
||||||
|
// writer's holds from the last writer's future clean replace.)
|
||||||
|
plan.skipWrite = true;
|
||||||
|
}
|
||||||
|
plan.wire = encodeUsageRecord(merged);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreign value from ANOTHER track: this instance is a cross-track copy (or was
|
||||||
|
// moved). Take a fresh identity; never overwrite the other's record.
|
||||||
|
plan.remint = true;
|
||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,10 +207,15 @@ std::vector<std::string> usageHeldPaths(
|
|||||||
bool anyInstanceLive) {
|
bool anyInstanceLive) {
|
||||||
std::vector<std::string> out;
|
std::vector<std::string> out;
|
||||||
std::unordered_set<std::string> seen;
|
std::unordered_set<std::string> seen;
|
||||||
|
// FAIL-SAFE NET: records exist but not one instance was identified live anywhere —
|
||||||
|
// indistinguishable from an identity-matcher failure, so protect EVERY record's
|
||||||
|
// paths rather than none (zero-identified must never degrade toward delete).
|
||||||
|
const bool protectAll = !records.empty() && !anyInstanceLive;
|
||||||
for (const UsageRecord& rec : records) {
|
for (const UsageRecord& rec : records) {
|
||||||
const bool live = rec.trackGuid.empty()
|
const bool live = protectAll ||
|
||||||
? anyInstanceLive
|
(rec.trackGuid.empty()
|
||||||
: (liveTrackGuids.count(rec.trackGuid) != 0);
|
? anyInstanceLive
|
||||||
|
: (liveTrackGuids.count(rec.trackGuid) != 0));
|
||||||
if (!live) continue;
|
if (!live) continue;
|
||||||
for (const UsageHold& h : rec.holds) {
|
for (const UsageHold& h : rec.holds) {
|
||||||
if (h.relativePath.empty()) continue;
|
if (h.relativePath.empty()) continue;
|
||||||
@@ -182,4 +225,53 @@ std::vector<std::string> usageHeldPaths(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UsageFoldResult foldUsageRecords(
|
||||||
|
const std::vector<std::optional<UsageRecord>>& decoded,
|
||||||
|
const std::unordered_set<std::string>& liveTrackGuids,
|
||||||
|
bool anyInstanceLive) {
|
||||||
|
UsageFoldResult result;
|
||||||
|
std::vector<UsageRecord> records;
|
||||||
|
records.reserve(decoded.size());
|
||||||
|
for (const std::optional<UsageRecord>& rec : decoded) {
|
||||||
|
if (!rec) {
|
||||||
|
// A present-but-unreadable record: it may protect ANYTHING, so the prune
|
||||||
|
// must halt outright — heldPaths is irrelevant once abortPrune is set (the
|
||||||
|
// caller deletes nothing).
|
||||||
|
result.abortPrune = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
records.push_back(*rec);
|
||||||
|
}
|
||||||
|
result.heldPaths = usageHeldPaths(records, liveTrackGuids, anyInstanceLive);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string toUpperAscii(const std::string& s) {
|
||||||
|
std::string out = s;
|
||||||
|
for (char& c : out)
|
||||||
|
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool identityMatches(const std::string& identity, const std::string& uidHexUpper,
|
||||||
|
const std::string& nameUpper,
|
||||||
|
const std::string& outputNameUpper) {
|
||||||
|
if (identity.empty()) return false;
|
||||||
|
const std::string up = toUpperAscii(identity);
|
||||||
|
// Primary: the 32-hex class UID embedded in REAPER's fx_ident rendering. Not
|
||||||
|
// guaranteed on every platform/REAPER build (byte-order of the rendered FUID vs
|
||||||
|
// REAPER's hex is unverified on Windows COM layout), hence the two name nets below
|
||||||
|
// — and the protect-all fold above them (see usageHeldPaths).
|
||||||
|
if (!uidHexUpper.empty() && up.find(uidHexUpper) != std::string::npos) return true;
|
||||||
|
// The module filename base ("REASAMPLER_9000") — fx_ident carries the .vst3 module
|
||||||
|
// path, so this is the alternative that works in the common case (the display name
|
||||||
|
// "REASAMPLER 9000", space-separated, can never match the filename form).
|
||||||
|
if (!outputNameUpper.empty() && up.find(outputNameUpper) != std::string::npos)
|
||||||
|
return true;
|
||||||
|
// The factory display name — matches original_name / renamed-instance renderings.
|
||||||
|
// Beta-substring over-protect is deliberate (see the header note): stable needles
|
||||||
|
// are substrings of beta ones, widening protection only — never a delete.
|
||||||
|
return !nameUpper.empty() && up.find(nameUpper) != std::string::npos;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
+130
-36
@@ -2,7 +2,7 @@
|
|||||||
// sample_usage — the pure core of the pS-usage seam: ReaSampler 9000 instances count
|
// sample_usage — the pure core of the pS-usage seam: ReaSampler 9000 instances count
|
||||||
// as USAGE for the prune. Each live instance PUBLISHES the captures it holds (its v10
|
// as USAGE for the prune. Each live instance PUBLISHES the captures it holds (its v10
|
||||||
// SampleRefs — sample ids + project-relative paths) to a per-instance project ext-state
|
// SampleRefs — sample ids + project-relative paths) to a per-instance project ext-state
|
||||||
// key ("usage_<instanceGuid>", see ext_keys.h); the EXTENSION reads every usage record
|
// key ("rsusage_<instanceGuid>", see ext_keys.h); the EXTENSION reads every usage record
|
||||||
// at prune-scan time, keeps only the records backed by a live ReaSampler 9000 FX
|
// at prune-scan time, keeps only the records backed by a live ReaSampler 9000 FX
|
||||||
// instance, and folds the surviving paths into the prune's `referenced` set — so a file
|
// instance, and folds the surviving paths into the prune's `referenced` set — so a file
|
||||||
// any live instance holds can never be an orphan and BANK_PRUNE_FOLDER can never
|
// any live instance holds can never be an orphan and BANK_PRUNE_FOLDER can never
|
||||||
@@ -21,7 +21,22 @@
|
|||||||
// bridge when it grabs a capture, so be it") and it does NOT weaken the read-only-BANK
|
// bridge when it grabs a capture, so be it") and it does NOT weaken the read-only-BANK
|
||||||
// invariant: the instrument publishes its OWN usage under its OWN per-instance key,
|
// invariant: the instrument publishes its OWN usage under its OWN per-instance key,
|
||||||
// and never touches banks/view/tail/assign or any other extension-owned key. The bridge
|
// and never touches banks/view/tail/assign or any other extension-owned key. The bridge
|
||||||
// enforces this structurally — its write entry point accepts only "usage_"-prefixed keys.
|
// enforces this structurally — its write entry point accepts only "rsusage_"-prefixed keys.
|
||||||
|
//
|
||||||
|
// -- THE SAFETY PROPERTY (overrides every other consideration) -----------------
|
||||||
|
//
|
||||||
|
// The un-prunable guarantee is a SAFETY property: every failure, ambiguity, or
|
||||||
|
// uncertainty in this seam must FAIL-SAFE toward PROTECT. Over-protection (prune skips a
|
||||||
|
// reclaimable file, or refuses to run at all) is an acceptable residual; under-protection
|
||||||
|
// (deleting a file an instance may still be playing) is a data-loss bug. Three fail-safe
|
||||||
|
// folds live in this pure module so they are provable without a DAW:
|
||||||
|
// * sibling-collision -> UNION, never clean-replace over a foreign writer (ownerNonce);
|
||||||
|
// * zero-identified -> records exist but NO instance was identified live -> protect
|
||||||
|
// ALL records' paths (an identity-matcher failure must never
|
||||||
|
// degrade toward delete);
|
||||||
|
// * unreadable record -> ABORT the prune entirely (foldUsageRecords.abortPrune — a
|
||||||
|
// record we cannot read may protect anything; halting deletes
|
||||||
|
// nothing).
|
||||||
//
|
//
|
||||||
// -- Liveness (no stale-key false-protect, no false-delete) --------------------
|
// -- Liveness (no stale-key false-protect, no false-delete) --------------------
|
||||||
//
|
//
|
||||||
@@ -45,22 +60,40 @@
|
|||||||
// instance (offline FX included — chain enumeration is chunk-level, so a parked
|
// instance (offline FX included — chain enumeration is chunk-level, so a parked
|
||||||
// instance still protects its holds). A record whose track GUID could not be resolved
|
// instance still protects its holds). A record whose track GUID could not be resolved
|
||||||
// at publish time (empty) counts while ANY ReaSampler 9000 instance exists in the
|
// at publish time (empty) counts while ANY ReaSampler 9000 instance exists in the
|
||||||
// project — the fail-safe fallback. The residual: a deleted instance whose track still
|
// project — the fail-safe fallback. And the identity-failure net: when records exist
|
||||||
// hosts a sibling 9000 keeps its record alive (false-PROTECT only — prune skips a file
|
// but ZERO instances were identified live anywhere, EVERY record's paths are protected
|
||||||
// it could have reclaimed; never the delete direction). Bounded, documented, accepted.
|
// (see the safety property above — indistinguishable from a matcher failure, so it may
|
||||||
|
// never resolve toward delete). Residuals: a deleted instance whose track still hosts a
|
||||||
|
// sibling 9000 keeps its record alive, and a project whose instances were all deleted
|
||||||
|
// keeps its leftover records protecting until an instance is identified again — both
|
||||||
|
// false-PROTECT only, bounded, documented, accepted.
|
||||||
//
|
//
|
||||||
// -- Identity & the copy problem (planUsagePublish) -----------------------------
|
// -- Identity & the copy problem (planUsagePublish) -----------------------------
|
||||||
//
|
//
|
||||||
// The publishing key is a minted per-instance GUID persisted in ComponentState (v11).
|
// The publishing key is a minted per-instance GUID persisted in ComponentState (v11).
|
||||||
// A persisted id is inherently COPYABLE (FX copy / track duplication clones component
|
// A persisted id is inherently COPYABLE (FX copy / track duplication clones component
|
||||||
// state byte-for-byte), so two live instances can wake up sharing one key. The publish
|
// state byte-for-byte), so two live instances can wake up sharing one key. Worse, two
|
||||||
// plan resolves every collision in the fail-safe direction:
|
// same-track copies converge on byte-identical wires, so "existing == what I last
|
||||||
// * existing value == what THIS instance wrote this lifetime -> clean replace (the
|
// wrote" is NOT a sound ownership test — a sibling's byte-identical write would pass
|
||||||
// normal single-owner path; holds the instance released genuinely drop).
|
// it, and a later clean replace would silently drop the sibling's holds (the delete
|
||||||
// * existing value is foreign but from the SAME track -> UNION of holds (a same-track
|
// direction). TWO in-wire facts close this:
|
||||||
// copy; neither sibling's holds may be dropped — over-protects until the next clean
|
// * ownerNonce — a per-LIFETIME nonce minted fresh in memory each instance lifetime,
|
||||||
// replace, never under-protects).
|
// NEVER persisted (a persisted nonce would clone with the state, recreating the
|
||||||
// * existing value is foreign from a DIFFERENT track -> RE-MINT (a cross-track copy
|
// ambiguity). Proves "exactly this incarnation wrote the key last".
|
||||||
|
// * unioned — a STICKY multi-writer poison flag. "I wrote the key last" does NOT
|
||||||
|
// imply "the key contains only my holds": after I union a sibling's holds under my
|
||||||
|
// own nonce, a later nonce-matching clean replace would drop them. So the first
|
||||||
|
// union sets unioned=true in the wire, and a unioned record REFUSES clean replace
|
||||||
|
// forever — every subsequent write is a union (holds only accumulate). Over-protect
|
||||||
|
// residual, accepted; a solo never-restarted instance keeps clean-replace
|
||||||
|
// semantics, and a remint starts a fresh un-poisoned key.
|
||||||
|
// The publish plan resolves every collision in the fail-safe direction:
|
||||||
|
// * existing ownerNonce == mine AND not unioned -> clean replace (sole writer,
|
||||||
|
// provably my content; holds the instance released genuinely drop).
|
||||||
|
// * same track with a foreign nonce, OR unioned -> UNION of holds, written with
|
||||||
|
// unioned=true (a same-track sibling, my own last-session record, or a
|
||||||
|
// multi-writer key; nothing may be dropped — over-protects, never under-protects).
|
||||||
|
// * foreign nonce, DIFFERENT track, not unioned-by-me -> RE-MINT (a cross-track copy
|
||||||
// or move; the newcomer takes a fresh identity and leaves the original's record
|
// or move; the newcomer takes a fresh identity and leaves the original's record
|
||||||
// untouched; a moved-away original's old record dies by the liveness rule).
|
// untouched; a moved-away original's old record dies by the liveness rule).
|
||||||
|
|
||||||
@@ -84,15 +117,22 @@ struct UsageHold {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// One instance's published usage: the REAPER track GUID it was hosted on at publish
|
// One instance's published usage: the REAPER track GUID it was hosted on at publish
|
||||||
// time ("{...}" canonical form; empty when the host context could not resolve one) plus
|
// time ("{...}" canonical form; empty when the host context could not resolve one), the
|
||||||
// every capture it holds. The record is self-contained — the extension needs nothing
|
// writing incarnation's per-LIFETIME ownerNonce (the exact "did I write this?" ownership
|
||||||
// from the instance beyond this value and the live FX enumeration.
|
// discriminator — see the copy-problem note above; never persisted in ComponentState),
|
||||||
|
// the sticky multi-writer `unioned` poison flag (once true, clean replace is refused
|
||||||
|
// forever — see the note above), plus every capture it holds. The record is
|
||||||
|
// self-contained — the extension needs nothing from the instance beyond this value and
|
||||||
|
// the live FX enumeration.
|
||||||
struct UsageRecord {
|
struct UsageRecord {
|
||||||
std::string trackGuid;
|
std::string trackGuid;
|
||||||
|
std::string ownerNonce;
|
||||||
|
bool unioned = false;
|
||||||
std::vector<UsageHold> holds;
|
std::vector<UsageHold> holds;
|
||||||
|
|
||||||
bool operator==(const UsageRecord& o) const {
|
bool operator==(const UsageRecord& o) const {
|
||||||
return trackGuid == o.trackGuid && holds == o.holds;
|
return trackGuid == o.trackGuid && ownerNonce == o.ownerNonce &&
|
||||||
|
unioned == o.unioned && holds == o.holds;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,22 +140,25 @@ struct UsageRecord {
|
|||||||
// ("rsusage1"), the same idiom as assignment_request / provenance, so arbitrary bytes
|
// ("rsusage1"), the same idiom as assignment_request / provenance, so arbitrary bytes
|
||||||
// in a GUID or path round-trip whole. Deterministic.
|
// in a GUID or path round-trip whole. Deterministic.
|
||||||
//
|
//
|
||||||
// FORMAT: "rsusage1" <len>':'<trackGuid> <len>':'<holdCount-decimal>
|
// FORMAT: "rsusage1" <len>':'<trackGuid> <len>':'<ownerNonce> <len>':'<unioned "0"|"1">
|
||||||
// then per hold: <len>':'<sampleId> <len>':'<relativePath>
|
// <len>':'<holdCount-decimal> then per hold: <len>':'<sampleId> <len>':'<relativePath>
|
||||||
std::string encodeUsageRecord(const UsageRecord& rec);
|
std::string encodeUsageRecord(const UsageRecord& rec);
|
||||||
|
|
||||||
// Parse a wire string produced by encodeUsageRecord. std::nullopt on malformed /
|
// Parse a wire string produced by encodeUsageRecord. std::nullopt on malformed /
|
||||||
// truncated / trailing-garbage input (never UB, never a partial value). The extension
|
// truncated / trailing-garbage input (never UB, never a partial value). The prune scan
|
||||||
// treats an undecodable record as absent — it can protect nothing it cannot read.
|
// treats an undecodable record as UNREADABLE and ABORTS (foldUsageRecords) — it must
|
||||||
|
// never proceed with protection it cannot read.
|
||||||
std::optional<UsageRecord> decodeUsageRecord(const std::string& wire);
|
std::optional<UsageRecord> decodeUsageRecord(const std::string& wire);
|
||||||
|
|
||||||
// The publish decision computed BEFORE a write (see the identity note above).
|
// The publish decision computed BEFORE a write (see the identity note above).
|
||||||
// * remint — true when the existing key value belongs to a live foreign instance
|
// * remint — true when the existing key value belongs to a live foreign instance
|
||||||
// on another track: the caller must mint a fresh instance GUID and
|
// on another track: the caller must mint a fresh instance GUID and
|
||||||
// write under the NEW key, leaving the existing record untouched.
|
// write under the NEW key, leaving the existing record untouched.
|
||||||
// * skipWrite — true when the write would be byte-identical to what this instance
|
// * skipWrite — true when the write would change nothing that matters: byte-identical
|
||||||
// already wrote this lifetime (idle reload tick) — skip the ext-state
|
// to the existing value (idle reload tick), or a union over an
|
||||||
// churn entirely.
|
// ALREADY-unioned record that adds no holds (the write would flip only
|
||||||
|
// the ownerNonce — redundant ext-state churn, skipped; a false->true
|
||||||
|
// unioned flip is never skipped, it is the multi-writer poison).
|
||||||
// * wire — the encoded value to write (mine, or the same-track union).
|
// * wire — the encoded value to write (mine, or the same-track union).
|
||||||
struct UsagePublishPlan {
|
struct UsagePublishPlan {
|
||||||
bool remint = false;
|
bool remint = false;
|
||||||
@@ -123,29 +166,80 @@ struct UsagePublishPlan {
|
|||||||
std::string wire;
|
std::string wire;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Decide what to write for `mine` given the key's current value and what this instance
|
// Decide what to write for `mine` given the key's current value. `mine.ownerNonce` is
|
||||||
// last wrote THIS LIFETIME (empty string = nothing yet this lifetime — a fresh load;
|
// THIS lifetime's nonce; `mine.unioned` is ignored (the plan computes the written
|
||||||
// the existing value is then this instance's own persisted record from the last
|
// flag). Branches, in order:
|
||||||
// session, OR a copy-source's record: same-track -> union, other-track -> remint).
|
// * existing absent/empty -> write mine (unioned=false — sole known writer).
|
||||||
// * existing absent or undecodable -> write mine.
|
// * existing undecodable -> write mine, unioned=false (this is MY key — a
|
||||||
// * existing == lastPublishedThisLifetime -> write mine (clean replace).
|
// sibling sharing it via copy always writes decodable records, so an undecodable
|
||||||
// * existing.trackGuid == mine.trackGuid -> write union(existing.holds, mine.holds)
|
// value is corruption; overwriting restores correct protection for my holds, and
|
||||||
// (existing-first order, de-duped).
|
// the prune side independently ABORTS while an unreadable record is present).
|
||||||
// * else -> remint = true, write mine (new key).
|
// * nonce match AND !unioned -> clean replace (sole writer, provably my content;
|
||||||
|
// released holds drop); skipWrite when
|
||||||
|
// byte-identical (idle reload tick).
|
||||||
|
// * same track OR unioned -> union(existing.holds, mine.holds), existing-first,
|
||||||
|
// de-duped, written with unioned=TRUE under my
|
||||||
|
// nonce — a sibling's holds are NEVER dropped. The
|
||||||
|
// false->true unioned flip is ALWAYS written (it is
|
||||||
|
// the poison that blocks the last writer's future
|
||||||
|
// clean replace); skipWrite only when the existing
|
||||||
|
// record is already unioned AND the union adds no
|
||||||
|
// holds (the write would change nonce only).
|
||||||
|
// * else (foreign, other track) -> remint = true, write mine (fresh un-poisoned key).
|
||||||
UsagePublishPlan planUsagePublish(const std::optional<std::string>& existing,
|
UsagePublishPlan planUsagePublish(const std::optional<std::string>& existing,
|
||||||
const std::string& lastPublishedThisLifetime,
|
|
||||||
const UsageRecord& mine);
|
const UsageRecord& mine);
|
||||||
|
|
||||||
// The prune-side fold: every project-relative path held by a LIVE instance, de-duped,
|
// The prune-side liveness fold: every project-relative path held by a LIVE instance,
|
||||||
// in (record, hold) input order. A record counts iff
|
// de-duped, in (record, hold) input order. A record counts iff
|
||||||
// * its trackGuid is non-empty and present in `liveTrackGuids` (a track that still
|
// * its trackGuid is non-empty and present in `liveTrackGuids` (a track that still
|
||||||
// exists AND still hosts >= 1 ReaSampler 9000 FX — the caller's enumeration), OR
|
// exists AND still hosts >= 1 ReaSampler 9000 FX — the caller's enumeration), OR
|
||||||
// * its trackGuid is empty and `anyInstanceLive` is true (the fail-safe fallback for
|
// * its trackGuid is empty and `anyInstanceLive` is true (the fail-safe fallback for
|
||||||
// a record published without a resolvable track context).
|
// a record published without a resolvable track context).
|
||||||
|
// FAIL-SAFE NET (the safety property): when `records` is non-empty and
|
||||||
|
// `anyInstanceLive` is false — records exist but NOT ONE instance was identified
|
||||||
|
// anywhere — EVERY record's paths are returned (protect-all). Zero identified with
|
||||||
|
// records present is indistinguishable from an identity-matcher failure, and a matcher
|
||||||
|
// failure must never resolve toward delete. (Residual: leftover records in a project
|
||||||
|
// whose instances were all genuinely deleted keep protecting — false-PROTECT only.)
|
||||||
// Holds with an empty relativePath are skipped (nothing to protect).
|
// Holds with an empty relativePath are skipped (nothing to protect).
|
||||||
std::vector<std::string> usageHeldPaths(
|
std::vector<std::string> usageHeldPaths(
|
||||||
const std::vector<UsageRecord>& records,
|
const std::vector<UsageRecord>& records,
|
||||||
const std::unordered_set<std::string>& liveTrackGuids,
|
const std::unordered_set<std::string>& liveTrackGuids,
|
||||||
bool anyInstanceLive);
|
bool anyInstanceLive);
|
||||||
|
|
||||||
|
// The prune-side entry fold over RAW read/decode results, one element per enumerated
|
||||||
|
// rsusage_* key: nullopt = the key was present but could not be read or decoded
|
||||||
|
// (oversized ext-state read, truncation, corruption). ANY nullopt sets abortPrune —
|
||||||
|
// the prune must HALT and delete nothing (an unreadable record may protect anything;
|
||||||
|
// proceeding with degraded protection is the delete direction). Otherwise delegates to
|
||||||
|
// usageHeldPaths (including its protect-all net).
|
||||||
|
struct UsageFoldResult {
|
||||||
|
bool abortPrune = false;
|
||||||
|
std::vector<std::string> heldPaths;
|
||||||
|
};
|
||||||
|
UsageFoldResult foldUsageRecords(
|
||||||
|
const std::vector<std::optional<UsageRecord>>& decoded,
|
||||||
|
const std::unordered_set<std::string>& liveTrackGuids,
|
||||||
|
bool anyInstanceLive);
|
||||||
|
|
||||||
|
// FX-identity match for the live-instance enumeration (pure so the matcher itself is
|
||||||
|
// testable; the shell only supplies REAPER's identity strings). `identity` is the value
|
||||||
|
// of an FX's "fx_ident" or "original_name" named-config parm; the three needles are the
|
||||||
|
// UPPERCASED channel constants:
|
||||||
|
// * uidHexUpper — the 32-hex VST3 class UID (instrument_drop::vstClassIdHex),
|
||||||
|
// * nameUpper — the factory display name ("REASAMPLER 9000"),
|
||||||
|
// * outputNameUpper— the .vst3 module filename base ("REASAMPLER_9000") — the form
|
||||||
|
// fx_ident is guaranteed to embed (it carries the module path),
|
||||||
|
// which the space-separated display name can never match.
|
||||||
|
// Substring, case-insensitive. NOTE the deliberate beta-substring over-protect: the
|
||||||
|
// stable needles are substrings of the beta ones ("REASAMPLER 9000" ⊂ "REASAMPLER 9000
|
||||||
|
// BETA", "REASAMPLER_9000" ⊂ "REASAMPLER_9000_BETA"), so a stable extension scanning a
|
||||||
|
// project with beta instances matches them too — a WIDER protected set only (fail-safe;
|
||||||
|
// it can never cause a delete).
|
||||||
|
bool identityMatches(const std::string& identity, const std::string& uidHexUpper,
|
||||||
|
const std::string& nameUpper, const std::string& outputNameUpper);
|
||||||
|
|
||||||
|
// ASCII-only uppercase (shared by the matcher and the shell's needle preparation).
|
||||||
|
std::string toUpperAscii(const std::string& s);
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
+107
-91
@@ -18,16 +18,17 @@
|
|||||||
|
|
||||||
#include "usage_scan.h"
|
#include "usage_scan.h"
|
||||||
|
|
||||||
#include <cctype>
|
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
#include <functional>
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "app_version.h" // vstPluginName (channel display-name fallback match)
|
#include "app_version.h" // vstPluginName / vstOutputName (channel name needles)
|
||||||
#include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix
|
#include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix
|
||||||
#include "instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex
|
#include "instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex
|
||||||
#include "sample_usage.h" // decodeUsageRecord, usageHeldPaths (the pure decisions)
|
#include "sample_usage.h" // identityMatches, foldUsageRecords (the pure decisions)
|
||||||
#include "track_guid.h" // guidString — the ONE canonical GUID key formatter
|
#include "track_guid.h" // guidString — the ONE canonical GUID key formatter
|
||||||
|
|
||||||
#define REAPERAPI_MINIMAL
|
#define REAPERAPI_MINIMAL
|
||||||
@@ -52,28 +53,53 @@ namespace reasampler {
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
std::string toUpperAscii(const std::string& s) {
|
// The three UPPERCASED channel needles identityMatches (pure, sample_usage) checks
|
||||||
std::string out = s;
|
// every FX identity string against. One instance drives the whole scan.
|
||||||
for (char& c : out)
|
struct FxIdentityNeedles {
|
||||||
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
std::string uidHexUpper; // 32-hex VST3 class UID (may not appear on all builds)
|
||||||
return out;
|
std::string outputNameUpper; // "REASAMPLER_9000" — the .vst3 filename base fx_ident embeds
|
||||||
|
std::string nameUpper; // "REASAMPLER 9000" — factory display name
|
||||||
|
};
|
||||||
|
|
||||||
|
// A named-config-parm getter abstracted over the FX-chain kind: track FX and take FX
|
||||||
|
// share the identical identity walk (fx_ident + original_name + container recursion),
|
||||||
|
// differing only in which REAPER getter reads the parm.
|
||||||
|
using FxParmGetter =
|
||||||
|
std::function<std::string(int fxId, const char* parm)>;
|
||||||
|
|
||||||
|
// True if any FX in the (possibly container-nested) sub-chain rooted at `fxId` is a
|
||||||
|
// ReaSampler 9000. BOTH fx_ident and original_name are checked on BOTH chain kinds (a
|
||||||
|
// renamed instance keeps its original_name; fx_ident carries the module path — the
|
||||||
|
// review's take-path gap is closed by sharing this one walk). Containers are walked via
|
||||||
|
// the documented container_count / container_item.X addressing (v7.06+); on a chain
|
||||||
|
// kind or REAPER version without containers the parm read returns empty and recursion
|
||||||
|
// is a no-op. `depth` bounds pathological nesting. fx_ident is queried per FX — chain
|
||||||
|
// enumeration is chunk-level, so OFFLINE instances match too (load-bearing: a
|
||||||
|
// Design-View-parked instance must keep protecting its holds).
|
||||||
|
bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId,
|
||||||
|
const FxIdentityNeedles& id, int depth) {
|
||||||
|
if (identityMatches(parm(fxId, "fx_ident"), id.uidHexUpper, id.nameUpper,
|
||||||
|
id.outputNameUpper) ||
|
||||||
|
identityMatches(parm(fxId, "original_name"), id.uidHexUpper, id.nameUpper,
|
||||||
|
id.outputNameUpper))
|
||||||
|
return true;
|
||||||
|
if (depth <= 0) return false;
|
||||||
|
const std::string countStr = parm(fxId, "container_count");
|
||||||
|
if (countStr.empty()) return false; // not a container
|
||||||
|
const int n = std::atoi(countStr.c_str());
|
||||||
|
for (int k = 0; k < n; ++k) {
|
||||||
|
const std::string item =
|
||||||
|
parm(fxId, ("container_item." + std::to_string(k)).c_str());
|
||||||
|
if (item.empty()) continue;
|
||||||
|
const int childId = std::atoi(item.c_str());
|
||||||
|
if (childId <= 0) continue;
|
||||||
|
if (fxSubtreeHasInstance(parm, childId, id, depth - 1)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Does this FX identity string name a ReaSampler 9000 of THIS channel? Primary match:
|
constexpr int kMaxContainerDepth = 8;
|
||||||
// fx_ident contains the channel's 32-hex class UID (REAPER renders VST3 idents with the
|
|
||||||
// UID hex embedded; case varies, so compare uppercased). Fallback: the identity carries
|
|
||||||
// the channel display name ("ReaSampler 9000" / "ReaSampler 9000 beta") — belt and
|
|
||||||
// braces for an fx_ident rendering that omits the hex. A false positive here only
|
|
||||||
// widens the protected set (fail-safe direction); it can never cause a delete.
|
|
||||||
bool identityMatches(const std::string& identity, const std::string& uidHexUpper,
|
|
||||||
const std::string& nameUpper) {
|
|
||||||
if (identity.empty()) return false;
|
|
||||||
const std::string up = toUpperAscii(identity);
|
|
||||||
if (!uidHexUpper.empty() && up.find(uidHexUpper) != std::string::npos) return true;
|
|
||||||
return !nameUpper.empty() && up.find(nameUpper) != std::string::npos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read one named config parm of a track FX into a string ("" on failure/absence).
|
|
||||||
std::string trackFxParm(MediaTrack* tr, int fxId, const char* parm) {
|
std::string trackFxParm(MediaTrack* tr, int fxId, const char* parm) {
|
||||||
char buf[2048] = {0};
|
char buf[2048] = {0};
|
||||||
if (!TrackFX_GetNamedConfigParm(tr, fxId, parm, buf, static_cast<int>(sizeof(buf))))
|
if (!TrackFX_GetNamedConfigParm(tr, fxId, parm, buf, static_cast<int>(sizeof(buf))))
|
||||||
@@ -81,47 +107,26 @@ std::string trackFxParm(MediaTrack* tr, int fxId, const char* parm) {
|
|||||||
return std::string(buf);
|
return std::string(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// True if any FX in the (possibly container-nested) sub-chain rooted at `fxId` is a
|
std::string takeFxParm(MediaItem_Take* take, int fxId, const char* parm) {
|
||||||
// ReaSampler 9000. Containers are walked via the documented container_count /
|
char buf[2048] = {0};
|
||||||
// container_item.X addressing (v7.06+); `depth` bounds pathological nesting. fx_ident
|
if (!TakeFX_GetNamedConfigParm(take, fxId, parm, buf, static_cast<int>(sizeof(buf))))
|
||||||
// is queried per FX — chain enumeration is chunk-level, so OFFLINE instances match too
|
return {};
|
||||||
// (load-bearing: a Design-View-parked instance must keep protecting its holds).
|
return std::string(buf);
|
||||||
bool trackFxSubtreeHasInstance(MediaTrack* tr, int fxId,
|
|
||||||
const std::string& uidHexUpper,
|
|
||||||
const std::string& nameUpper, int depth) {
|
|
||||||
if (identityMatches(trackFxParm(tr, fxId, "fx_ident"), uidHexUpper, nameUpper) ||
|
|
||||||
identityMatches(trackFxParm(tr, fxId, "original_name"), uidHexUpper, nameUpper))
|
|
||||||
return true;
|
|
||||||
if (depth <= 0) return false;
|
|
||||||
const std::string countStr = trackFxParm(tr, fxId, "container_count");
|
|
||||||
if (countStr.empty()) return false; // not a container
|
|
||||||
const int n = std::atoi(countStr.c_str());
|
|
||||||
for (int k = 0; k < n; ++k) {
|
|
||||||
const std::string item =
|
|
||||||
trackFxParm(tr, fxId, ("container_item." + std::to_string(k)).c_str());
|
|
||||||
if (item.empty()) continue;
|
|
||||||
const int childId = std::atoi(item.c_str());
|
|
||||||
if (childId <= 0) continue;
|
|
||||||
if (trackFxSubtreeHasInstance(tr, childId, uidHexUpper, nameUpper, depth - 1))
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// True if `tr` hosts >= 1 ReaSampler 9000 anywhere: normal chain, record/input chain
|
// True if `tr` hosts >= 1 ReaSampler 9000 anywhere: normal chain, record/input chain
|
||||||
// (index | 0x1000000), containers recursively.
|
// (index | 0x1000000), containers recursively.
|
||||||
bool trackHasInstance(MediaTrack* tr, const std::string& uidHexUpper,
|
bool trackHasInstance(MediaTrack* tr, const FxIdentityNeedles& id) {
|
||||||
const std::string& nameUpper) {
|
const FxParmGetter parm = [tr](int fxId, const char* p) {
|
||||||
constexpr int kMaxContainerDepth = 8;
|
return trackFxParm(tr, fxId, p);
|
||||||
|
};
|
||||||
const int n = TrackFX_GetCount(tr);
|
const int n = TrackFX_GetCount(tr);
|
||||||
for (int i = 0; i < n; ++i) {
|
for (int i = 0; i < n; ++i) {
|
||||||
if (trackFxSubtreeHasInstance(tr, i, uidHexUpper, nameUpper, kMaxContainerDepth))
|
if (fxSubtreeHasInstance(parm, i, id, kMaxContainerDepth)) return true;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
const int rec = TrackFX_GetRecCount(tr);
|
const int rec = TrackFX_GetRecCount(tr);
|
||||||
for (int i = 0; i < rec; ++i) {
|
for (int i = 0; i < rec; ++i) {
|
||||||
if (trackFxSubtreeHasInstance(tr, 0x1000000 + i, uidHexUpper, nameUpper,
|
if (fxSubtreeHasInstance(parm, 0x1000000 + i, id, kMaxContainerDepth))
|
||||||
kMaxContainerDepth))
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -129,50 +134,53 @@ bool trackHasInstance(MediaTrack* tr, const std::string& uidHexUpper,
|
|||||||
|
|
||||||
// True if any take FX on `item` is a ReaSampler 9000 (all takes, not just active — a
|
// True if any take FX on `item` is a ReaSampler 9000 (all takes, not just active — a
|
||||||
// non-active take's instance still exists in the project and reactivates with the
|
// non-active take's instance still exists in the project and reactivates with the
|
||||||
// take). No container recursion here: take chains are queried flat, and a sampler
|
// take). The SAME identity walk as the track path: fx_ident + original_name + container
|
||||||
// nested in a take-FX container is exotic enough that the empty-trackGuid any-instance
|
// recursion (an unrecognized exotic still lands in the pure protect-all net — records
|
||||||
// fallback (sample_usage liveness rule) is the documented safety net.
|
// with zero identified instances protect everything rather than nothing).
|
||||||
bool itemHasInstance(MediaItem* item, const std::string& uidHexUpper,
|
bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) {
|
||||||
const std::string& nameUpper) {
|
|
||||||
const int takes = CountTakes(item);
|
const int takes = CountTakes(item);
|
||||||
for (int t = 0; t < takes; ++t) {
|
for (int t = 0; t < takes; ++t) {
|
||||||
MediaItem_Take* take = GetMediaItemTake(item, t);
|
MediaItem_Take* take = GetMediaItemTake(item, t);
|
||||||
if (!take) continue;
|
if (!take) continue;
|
||||||
|
const FxParmGetter parm = [take](int fxId, const char* p) {
|
||||||
|
return takeFxParm(take, fxId, p);
|
||||||
|
};
|
||||||
const int n = TakeFX_GetCount(take);
|
const int n = TakeFX_GetCount(take);
|
||||||
for (int i = 0; i < n; ++i) {
|
for (int i = 0; i < n; ++i) {
|
||||||
char buf[2048] = {0};
|
if (fxSubtreeHasInstance(parm, i, id, kMaxContainerDepth)) return true;
|
||||||
if (TakeFX_GetNamedConfigParm(take, i, "fx_ident", buf,
|
|
||||||
static_cast<int>(sizeof(buf))) &&
|
|
||||||
identityMatches(buf, uidHexUpper, nameUpper))
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Growing GetProjExtState read (the persist.cpp idiom): the usage record scales with
|
// Growing GetProjExtState read (the persist.cpp idiom): the usage record scales with
|
||||||
// the hold count, so a fixed buffer risks a truncated decode — and an undecodable
|
// the hold count, so a fixed buffer risks a truncated decode. Returns nullopt when the
|
||||||
// record protects nothing, which is the DANGEROUS direction here. Empty on absence.
|
// key cannot be read WHOLE — absent-after-enumeration (rv <= 0) or pathologically large
|
||||||
std::string readExtStateValue(ReaProject* proj, const char* key) {
|
// (> 16 MB give-up). The caller only queries keys the enumeration just listed, so a
|
||||||
|
// nullopt here is a PRESENT-BUT-UNREADABLE record: it folds to abortPrune (fail-safe —
|
||||||
|
// silently reduced protection is the delete direction).
|
||||||
|
std::optional<std::string> readExtStateValue(ReaProject* proj, const char* key) {
|
||||||
for (int cap = 1 << 12; cap <= (1 << 24); cap <<= 2) {
|
for (int cap = 1 << 12; cap <= (1 << 24); cap <<= 2) {
|
||||||
std::vector<char> buf(static_cast<std::size_t>(cap), '\0');
|
std::vector<char> buf(static_cast<std::size_t>(cap), '\0');
|
||||||
const int rv = GetProjExtState(proj, kProjExtNamespace(), key, buf.data(), cap);
|
const int rv = GetProjExtState(proj, kProjExtNamespace(), key, buf.data(), cap);
|
||||||
if (rv <= 0) return {};
|
if (rv <= 0) return std::nullopt;
|
||||||
std::string s(buf.data());
|
std::string s(buf.data());
|
||||||
if (static_cast<int>(s.size()) + 1 < cap) return s;
|
if (static_cast<int>(s.size()) + 1 < cap) return s;
|
||||||
// else possibly truncated -> grow and retry
|
// else possibly truncated -> grow and retry
|
||||||
}
|
}
|
||||||
return {};
|
return std::nullopt; // > 16 MB — unreadable whole, never "absent"
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
std::vector<std::string> liveInstanceHeldPaths(void* projOpaque) {
|
UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
|
||||||
ReaProject* proj = static_cast<ReaProject*>(projOpaque);
|
ReaProject* proj = static_cast<ReaProject*>(projOpaque);
|
||||||
|
UsageScanResult result;
|
||||||
|
|
||||||
// 1. Enumerate the usage_* keys and decode each record. Key names first (values via
|
// 1. Enumerate the rsusage_* keys and read+decode each record. Key names first
|
||||||
// the growing reader — EnumProjExtState's fixed val buffer could truncate a large
|
// (values via the growing reader — EnumProjExtState's fixed val buffer could
|
||||||
// record, and a truncated record decodes to nothing = protects nothing).
|
// truncate a large record). A nullopt element = present-but-unreadable/
|
||||||
|
// undecodable -> the pure fold ABORTS the prune.
|
||||||
std::vector<std::string> usageKeys;
|
std::vector<std::string> usageKeys;
|
||||||
{
|
{
|
||||||
char keyBuf[256];
|
char keyBuf[256];
|
||||||
@@ -186,26 +194,30 @@ std::vector<std::string> liveInstanceHeldPaths(void* projOpaque) {
|
|||||||
if (key.compare(0, prefix.size(), prefix) == 0) usageKeys.push_back(key);
|
if (key.compare(0, prefix.size(), prefix) == 0) usageKeys.push_back(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
std::vector<UsageRecord> records;
|
if (usageKeys.empty()) return result; // no instance ever published — skip the scan
|
||||||
records.reserve(usageKeys.size());
|
|
||||||
for (const std::string& key : usageKeys) {
|
|
||||||
const std::string value = readExtStateValue(proj, key.c_str());
|
|
||||||
if (value.empty()) continue;
|
|
||||||
if (std::optional<UsageRecord> rec = decodeUsageRecord(value)) {
|
|
||||||
records.push_back(std::move(*rec));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (records.empty()) return {}; // no instance ever published — skip the FX scan
|
|
||||||
|
|
||||||
// 2. Enumerate live ReaSampler 9000 hosts. One channel-frozen identity pair drives
|
std::vector<std::optional<UsageRecord>> decoded;
|
||||||
|
decoded.reserve(usageKeys.size());
|
||||||
|
for (const std::string& key : usageKeys) {
|
||||||
|
const std::optional<std::string> value = readExtStateValue(proj, key.c_str());
|
||||||
|
if (!value) {
|
||||||
|
decoded.push_back(std::nullopt); // unreadable -> abort (pure fold)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
decoded.push_back(decodeUsageRecord(*value)); // undecodable -> nullopt -> abort
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Enumerate live ReaSampler 9000 hosts. One channel-frozen needle set drives
|
||||||
// every match; a track needs only ONE instance to keep all its records live.
|
// every match; a track needs only ONE instance to keep all its records live.
|
||||||
const std::string uidHexUpper = toUpperAscii(vstClassIdHex());
|
FxIdentityNeedles id;
|
||||||
const std::string nameUpper = toUpperAscii(vstPluginName());
|
id.uidHexUpper = toUpperAscii(vstClassIdHex());
|
||||||
|
id.outputNameUpper = toUpperAscii(vstOutputName());
|
||||||
|
id.nameUpper = toUpperAscii(vstPluginName());
|
||||||
std::unordered_set<std::string> liveTrackGuids;
|
std::unordered_set<std::string> liveTrackGuids;
|
||||||
bool anyLive = false;
|
bool anyLive = false;
|
||||||
|
|
||||||
if (MediaTrack* master = GetMasterTrack(proj)) {
|
if (MediaTrack* master = GetMasterTrack(proj)) {
|
||||||
if (trackHasInstance(master, uidHexUpper, nameUpper)) {
|
if (trackHasInstance(master, id)) {
|
||||||
liveTrackGuids.insert(guidString(master));
|
liveTrackGuids.insert(guidString(master));
|
||||||
anyLive = true;
|
anyLive = true;
|
||||||
}
|
}
|
||||||
@@ -214,7 +226,7 @@ std::vector<std::string> liveInstanceHeldPaths(void* projOpaque) {
|
|||||||
for (int i = 0; i < trackCount; ++i) {
|
for (int i = 0; i < trackCount; ++i) {
|
||||||
MediaTrack* tr = GetTrack(proj, i);
|
MediaTrack* tr = GetTrack(proj, i);
|
||||||
if (!tr) continue;
|
if (!tr) continue;
|
||||||
if (trackHasInstance(tr, uidHexUpper, nameUpper)) {
|
if (trackHasInstance(tr, id)) {
|
||||||
liveTrackGuids.insert(guidString(tr));
|
liveTrackGuids.insert(guidString(tr));
|
||||||
anyLive = true;
|
anyLive = true;
|
||||||
}
|
}
|
||||||
@@ -225,7 +237,7 @@ std::vector<std::string> liveInstanceHeldPaths(void* projOpaque) {
|
|||||||
for (int i = 0; i < itemCount; ++i) {
|
for (int i = 0; i < itemCount; ++i) {
|
||||||
MediaItem* item = GetMediaItem(proj, i);
|
MediaItem* item = GetMediaItem(proj, i);
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
if (itemHasInstance(item, uidHexUpper, nameUpper)) {
|
if (itemHasInstance(item, id)) {
|
||||||
if (MediaTrack* tr = GetMediaItemTrack(item)) {
|
if (MediaTrack* tr = GetMediaItemTrack(item)) {
|
||||||
liveTrackGuids.insert(guidString(tr));
|
liveTrackGuids.insert(guidString(tr));
|
||||||
}
|
}
|
||||||
@@ -233,8 +245,12 @@ std::vector<std::string> liveInstanceHeldPaths(void* projOpaque) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. The pure liveness fold decides which records count.
|
// 3. The pure fold decides: abort on any unreadable record; protect-all when zero
|
||||||
return usageHeldPaths(records, liveTrackGuids, anyLive);
|
// instances were identified; otherwise the per-record liveness rule.
|
||||||
|
const UsageFoldResult fold = foldUsageRecords(decoded, liveTrackGuids, anyLive);
|
||||||
|
result.abortPrune = fold.abortPrune;
|
||||||
|
result.heldPaths = fold.heldPaths;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
+30
-18
@@ -1,26 +1,30 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// usage_scan — the EXTENSION-side shell of the pS-usage seam (see sample_usage.h for
|
// usage_scan — the EXTENSION-side shell of the pS-usage seam (see sample_usage.h for
|
||||||
// the pure core and the full design note). At prune-scan time it answers ONE question:
|
// the pure core, the fail-safe folds, and the full design note). At prune-scan time it
|
||||||
// which project-relative bank paths are held by a LIVE ReaSampler 9000 instance?
|
// answers ONE question: which project-relative bank paths are held by a LIVE ReaSampler
|
||||||
|
// 9000 instance — or must the prune ABORT because a usage record could not be read?
|
||||||
//
|
//
|
||||||
// Three reads, no writes (the prune scan's READ-ONLY contract holds):
|
// Three reads, no writes (the prune scan's READ-ONLY contract holds):
|
||||||
// 1. Enumerate every "usage_<guid>" key in the "reasampler" ext-state namespace
|
// 1. Enumerate every "rsusage_<guid>" key in the "reasampler" ext-state namespace
|
||||||
// (EnumProjExtState) and decode each record (sample_usage wire).
|
// (EnumProjExtState) and decode each record (sample_usage wire). A key that is
|
||||||
|
// present but cannot be read or decoded folds to abortPrune (fail-safe: an
|
||||||
|
// unreadable record may protect anything, so the prune halts and deletes nothing).
|
||||||
// 2. Enumerate every ReaSampler 9000 FX instance in the project — all tracks
|
// 2. Enumerate every ReaSampler 9000 FX instance in the project — all tracks
|
||||||
// (master included), normal + record/input chains, FX containers recursively, and
|
// (master included), normal + record/input chains, FX containers recursively, and
|
||||||
// take FX — matching by fx_ident containing this channel's VST3 class-UID hex
|
// take FX (same container recursion) — matching each FX's fx_ident AND
|
||||||
// (instrument_drop::vstClassIdHex, the same frozen constants the factory
|
// original_name via the pure sample_usage::identityMatches (class-UID hex, module
|
||||||
// registers) with the channel display name as a fallback match. OFFLINE FX are
|
// filename base, display name; see the matcher note there).
|
||||||
// included: chain enumeration is chunk-level, so a Design-View-parked instance
|
// 3. Fold with the pure liveness rule (sample_usage::foldUsageRecords /
|
||||||
// still protects its holds (the reason the instrument never clears its own key —
|
// usageHeldPaths): a record counts iff its publishing track still hosts >= 1
|
||||||
// see sample_usage.h).
|
// instance; a record with no track context counts while any instance exists; and
|
||||||
// 3. Fold with the pure liveness rule (sample_usage::usageHeldPaths): a record counts
|
// when records exist but ZERO instances were identified anywhere, EVERY record's
|
||||||
// iff its publishing track still hosts >= 1 instance (or, for a record with no
|
// paths are protected (the identity-failure net — a matcher failure must never
|
||||||
// track context, iff any instance exists at all).
|
// degrade toward delete).
|
||||||
//
|
//
|
||||||
// The result feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans, so
|
// The result feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans, so
|
||||||
// `referenced` = bank references ∪ live-instance holds — a held capture can never be
|
// `referenced` = bank references ∪ live-instance holds — a held capture can never be
|
||||||
// an orphan, and BANK_PRUNE_FOLDER (the only deletion authority) can never delete it.
|
// an orphan, and BANK_PRUNE_FOLDER (the only deletion authority) can never delete it.
|
||||||
|
// abortPrune propagates through PruneScan/PruneReport to the action, which halts.
|
||||||
//
|
//
|
||||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). The
|
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). The
|
||||||
@@ -32,10 +36,18 @@
|
|||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
// Every project-relative path held by a live ReaSampler 9000 instance in `proj`
|
// The scan outcome. When abortPrune is true a present rsusage_* record could not be
|
||||||
// (nullptr = active project), de-duped, in record order. Empty when no usage records
|
// read or decoded — the caller MUST halt the prune (delete nothing); heldPaths is then
|
||||||
// exist (the common no-instances case — the FX enumeration is skipped entirely).
|
// meaningless (left empty). Otherwise heldPaths is every project-relative path held by
|
||||||
// READ-ONLY: no ext-state write, no project mutation.
|
// a live ReaSampler 9000 instance, de-duped, in record order — empty in the common
|
||||||
std::vector<std::string> liveInstanceHeldPaths(void* proj);
|
// no-records case (the FX enumeration is skipped entirely).
|
||||||
|
struct UsageScanResult {
|
||||||
|
bool abortPrune = false;
|
||||||
|
std::vector<std::string> heldPaths;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scan `proj` (nullptr = active project). READ-ONLY: no ext-state write, no project
|
||||||
|
// mutation.
|
||||||
|
UsageScanResult liveInstanceHeldPaths(void* proj);
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
@@ -113,18 +113,24 @@ bool ReaperBridge::writeUsageExtState(const std::string& usageKey,
|
|||||||
const std::string& value) {
|
const std::string& value) {
|
||||||
if (!setProjExtState_ || !hostApp_) return false;
|
if (!setProjExtState_ || !hostApp_) return false;
|
||||||
// STRUCTURAL read-only-bank guard: this module writes usage keys and nothing else.
|
// STRUCTURAL read-only-bank guard: this module writes usage keys and nothing else.
|
||||||
// A non-"usage_" key is a programming error upstream — refuse rather than widen the
|
// A non-"rsusage_" key is a programming error upstream — refuse rather than widen
|
||||||
// instrument's write surface (banks/view/tail/assign stay extension-owned).
|
// the instrument's write surface (banks/view/tail/assign stay extension-owned).
|
||||||
const std::string prefix = kProjExtUsageKeyPrefix;
|
const std::string prefix = kProjExtUsageKeyPrefix;
|
||||||
if (usageKey.compare(0, prefix.size(), prefix) != 0) return false;
|
if (usageKey.compare(0, prefix.size(), prefix) != 0) return false;
|
||||||
|
|
||||||
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
|
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
|
||||||
void* proj = reaper->getReaperParent(3); // null = current project (same as reads)
|
void* proj = reaper->getReaperParent(3); // null = current project (same as reads)
|
||||||
setProjExtState_(proj, kProjExtNamespace(), usageKey.c_str(), value.c_str());
|
// SetProjExtState returns "the size of the state for this extname" (SDK ~6288) —
|
||||||
|
// after storing our non-empty value the namespace state is necessarily > 0, so a
|
||||||
|
// <= 0 return means the write did not land. Reported to the caller (the publish
|
||||||
|
// path retries on the next reload tick); a silently-dropped record would leave the
|
||||||
|
// instance's holds unprotected.
|
||||||
|
const int rv =
|
||||||
|
setProjExtState_(proj, kProjExtNamespace(), usageKey.c_str(), value.c_str());
|
||||||
// Deliberately NO MarkProjectDirty: a usage change always accompanies a component-
|
// Deliberately NO MarkProjectDirty: a usage change always accompanies a component-
|
||||||
// state change that already dirties the project; an idempotent load-time republish
|
// state change that already dirties the project; an idempotent load-time republish
|
||||||
// must not flag an untouched project as modified.
|
// must not flag an untouched project as modified.
|
||||||
return true;
|
return rv > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string ReaperBridge::currentTrackGuid() {
|
std::string ReaperBridge::currentTrackGuid() {
|
||||||
|
|||||||
@@ -58,13 +58,15 @@ public:
|
|||||||
std::string activeProjectDir();
|
std::string activeProjectDir();
|
||||||
|
|
||||||
// Write THIS INSTANCE's usage record (pS-usage): the ONE sanctioned instrument-side
|
// Write THIS INSTANCE's usage record (pS-usage): the ONE sanctioned instrument-side
|
||||||
// ext-state write. `usageKey` MUST carry the "usage_" prefix (ext_keys.h's
|
// ext-state write. `usageKey` MUST carry the "rsusage_" prefix (ext_keys.h's
|
||||||
// usageKeyFor) — any other key is REFUSED here, so the read-only-BANK invariant is
|
// usageKeyFor) — any other key is REFUSED here, so the read-only-BANK invariant is
|
||||||
// enforced structurally: this module can publish the instance's own usage and
|
// enforced structurally: this module can publish the instance's own usage and
|
||||||
// nothing else (banks/view/tail/assign remain unwritable from the instrument).
|
// nothing else (banks/view/tail/assign remain unwritable from the instrument).
|
||||||
// Returns true iff written. NOT RT-safe (calls into REAPER) — publish sites are the
|
// Returns true iff written (the SetProjExtState return is checked — a dropped
|
||||||
// off-audio-thread reload path only. Deliberately does NOT mark the project dirty:
|
// write must not silently claim protection). NOT RT-safe (calls into REAPER) —
|
||||||
// a usage change always rides a component-state change that already does.
|
// publish sites are the off-audio-thread reload path only. Deliberately does NOT
|
||||||
|
// mark the project dirty: a usage change always rides a component-state change
|
||||||
|
// that already does.
|
||||||
bool writeUsageExtState(const std::string& usageKey, const std::string& value);
|
bool writeUsageExtState(const std::string& usageKey, const std::string& value);
|
||||||
|
|
||||||
// The canonical "{XXXXXXXX-...}" GUID string of the track hosting this FX instance
|
// The canonical "{XXXXXXXX-...}" GUID string of the track hosting this FX instance
|
||||||
|
|||||||
@@ -50,10 +50,12 @@ constexpr std::size_t kPreserveVoiceCap = 8;
|
|||||||
constexpr float kGainRampRate = 1.0f / 960.0f; // 960 samples @ 48 kHz ≈ 20 ms
|
constexpr float kGainRampRate = 1.0f / 960.0f; // 960 samples @ 48 kHz ≈ 20 ms
|
||||||
constexpr float kGainRampSnap = kGainRampRate * 0.5f;
|
constexpr float kGainRampSnap = kGainRampRate * 0.5f;
|
||||||
|
|
||||||
// pS-usage: mint a fresh per-instance publish identity — 32 lowercase hex chars from the
|
// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy
|
||||||
// OS entropy source. Uniqueness (not cryptographic strength) is the requirement: two
|
// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the
|
||||||
// instances sharing a key is the copy-collision planUsagePublish resolves fail-safe
|
// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic
|
||||||
// anyway; the mint just makes accidental collision vanishingly unlikely. Off-thread only.
|
// strength) is the requirement: two instances sharing a key is the copy-collision
|
||||||
|
// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision
|
||||||
|
// vanishingly unlikely. Off-thread only.
|
||||||
std::string mintUsageInstanceGuid() {
|
std::string mintUsageInstanceGuid() {
|
||||||
std::random_device rd;
|
std::random_device rd;
|
||||||
std::mt19937_64 gen((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
|
std::mt19937_64 gen((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
|
||||||
@@ -277,13 +279,13 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
sampleRefs_ = cs.sampleRefs;
|
sampleRefs_ = cs.sampleRefs;
|
||||||
}
|
}
|
||||||
// pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty —
|
// pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty —
|
||||||
// minted on first publish). lastPublishedUsageWire_ resets: a restored blob is a NEW
|
// minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME
|
||||||
// LIFETIME for the copy-collision analysis (planUsagePublish must compare the key's
|
// for the copy-collision analysis (the fresh nonce means this incarnation can never
|
||||||
// current value against what THIS incarnation wrote, not a previous one's writes).
|
// be mistaken for the previous one's writes — or for a copy-sibling's).
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(usageMutex_);
|
std::lock_guard<std::mutex> lock(usageMutex_);
|
||||||
instanceGuid_ = cs.instanceGuid;
|
instanceGuid_ = cs.instanceGuid;
|
||||||
lastPublishedUsageWire_.clear();
|
usageNonce_.clear();
|
||||||
}
|
}
|
||||||
// A new blob is new facts: a staleness proof latched against the PREVIOUS state does
|
// A new blob is new facts: a staleness proof latched against the PREVIOUS state does
|
||||||
// not carry over (#A — the legacy lift gets one fresh run per restored state).
|
// not carry over (#A — the legacy lift gets one fresh run per restored state).
|
||||||
@@ -664,11 +666,16 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
|||||||
// holds the prune would otherwise keep protecting).
|
// holds the prune would otherwise keep protecting).
|
||||||
if (instanceGuid_.empty() && mine.holds.empty()) return;
|
if (instanceGuid_.empty() && mine.holds.empty()) return;
|
||||||
if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
|
if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
|
||||||
|
// The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so
|
||||||
|
// planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track
|
||||||
|
// sibling's byte-identical hold set can never pass as ours (its nonce differs), so
|
||||||
|
// siblings always union and never clean-replace over each other's held paths.
|
||||||
|
if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid();
|
||||||
|
mine.ownerNonce = usageNonce_;
|
||||||
|
|
||||||
const std::optional<std::string> existing =
|
const std::optional<std::string> existing =
|
||||||
bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_));
|
bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_));
|
||||||
const UsagePublishPlan plan =
|
const UsagePublishPlan plan = planUsagePublish(existing, mine);
|
||||||
planUsagePublish(existing, lastPublishedUsageWire_, mine);
|
|
||||||
if (plan.remint) {
|
if (plan.remint) {
|
||||||
// This state was cloned onto another track (FX copy / track duplication): take a
|
// This state was cloned onto another track (FX copy / track duplication): take a
|
||||||
// fresh identity and leave the original's record untouched. The abandoned old
|
// fresh identity and leave the original's record untouched. The abandoned old
|
||||||
@@ -676,11 +683,9 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
|||||||
// longer hosts an instance. getState persists the new guid on the next save.
|
// longer hosts an instance. getState persists the new guid on the next save.
|
||||||
instanceGuid_ = mintUsageInstanceGuid();
|
instanceGuid_ = mintUsageInstanceGuid();
|
||||||
} else if (plan.skipWrite) {
|
} else if (plan.skipWrite) {
|
||||||
return; // byte-identical to what this lifetime already wrote — idle tick
|
return; // idle tick, or a union that adds nothing — no ext-state churn
|
||||||
}
|
|
||||||
if (bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire)) {
|
|
||||||
lastPublishedUsageWire_ = plan.wire;
|
|
||||||
}
|
}
|
||||||
|
bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
|
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ private:
|
|||||||
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
|
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
|
||||||
|
|
||||||
// pS-usage: publish this instance's held captures to its per-instance ext-state key
|
// pS-usage: publish this instance's held captures to its per-instance ext-state key
|
||||||
// ("usage_<instanceGuid>") so the extension's prune counts them as referenced — a
|
// ("rsusage_<instanceGuid>") so the extension's prune counts them as referenced — a
|
||||||
// capture a live instance holds can never be pruned. Called at the end of every
|
// capture a live instance holds can never be pruned. Called at the end of every
|
||||||
// reloadInstrument (the ONE choke point every play-set change funnels through:
|
// reloadInstrument (the ONE choke point every play-set change funnels through:
|
||||||
// selection change, zone edits, assignment consume, bank refresh, setState load), so
|
// selection change, zone edits, assignment consume, bank refresh, setState load), so
|
||||||
@@ -381,16 +381,21 @@ private:
|
|||||||
std::mutex refsMutex_;
|
std::mutex refsMutex_;
|
||||||
SampleRefs sampleRefs_;
|
SampleRefs sampleRefs_;
|
||||||
|
|
||||||
// pS-usage publish identity + lifetime memory (see publishUsage). instanceGuid_ is
|
// pS-usage publish identity + lifetime nonce (see publishUsage). instanceGuid_ is
|
||||||
// the persisted per-instance identity (ComponentState v11; empty until first
|
// the persisted per-instance identity (ComponentState v11; empty until first
|
||||||
// publish); lastPublishedUsageWire_ is what THIS lifetime last wrote — the
|
// publish); usageNonce_ is THIS incarnation's per-LIFETIME owner nonce, carried
|
||||||
// planUsagePublish discriminator between "my own key" (clean replace) and "a
|
// INSIDE the published wire (UsageRecord.ownerNonce) — planUsagePublish's exact
|
||||||
// copy-source's key" (union / re-mint), cleared on setState (a new blob is a new
|
// ownership discriminator between "my own write" (clean replace) and "a foreign
|
||||||
// lifetime for the collision analysis). Guarded by usageMutex_ (publish runs under
|
// writer" (union / re-mint). NEVER persisted: a persisted nonce would clone with
|
||||||
|
// the state on FX copy, and two same-track copies converging on byte-identical
|
||||||
|
// wires is exactly the ambiguity the nonce exists to break (a wire-equality
|
||||||
|
// discriminator let sibling A clean-replace over sibling B's still-held paths —
|
||||||
|
// the delete direction). Minted lazily on first publish; cleared on setState (a
|
||||||
|
// restored blob is a new lifetime). Guarded by usageMutex_ (publish runs under
|
||||||
// reloadMutex_ but getState/setState do not).
|
// reloadMutex_ but getState/setState do not).
|
||||||
std::mutex usageMutex_;
|
std::mutex usageMutex_;
|
||||||
std::string instanceGuid_;
|
std::string instanceGuid_;
|
||||||
std::string lastPublishedUsageWire_;
|
std::string usageNonce_;
|
||||||
|
|
||||||
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadInstrument);
|
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadInstrument);
|
||||||
// guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read
|
// guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read
|
||||||
|
|||||||
@@ -542,7 +542,7 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
|||||||
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
|
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
|
||||||
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE
|
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE
|
||||||
// length + guid bytes; the minted per-instance identity the usage publisher keys its
|
// length + guid bytes; the minted per-instance identity the usage publisher keys its
|
||||||
// "usage_<guid>" ext-state record under, see sample_usage.h), then a 4-byte LE
|
// "rsusage_<guid>" ext-state record under, see sample_usage.h), then a 4-byte LE
|
||||||
// selection-id length + id bytes, then the CURRENT zones payload (identical to
|
// selection-id length + id bytes, then the CURRENT zones payload (identical to
|
||||||
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
|
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
|
||||||
// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the
|
// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the
|
||||||
@@ -616,7 +616,7 @@ struct ComponentState {
|
|||||||
// path once (then re-saves self-contained).
|
// path once (then re-saves self-contained).
|
||||||
SampleRefs sampleRefs;
|
SampleRefs sampleRefs;
|
||||||
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
|
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
|
||||||
// "usage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
|
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
|
||||||
// seam). Persisted so the key is stable across sessions (records do not proliferate
|
// seam). Persisted so the key is stable across sessions (records do not proliferate
|
||||||
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
|
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
|
||||||
// mints one on first publish, and RE-mints when the publish plan detects this state
|
// mints one on first publish, and RE-mints when the publish plan detects this state
|
||||||
|
|||||||
+300
-72
@@ -1,19 +1,26 @@
|
|||||||
// Standalone tests for reasampler::sample_usage — no REAPER, no framework.
|
// Standalone tests for reasampler::sample_usage — no REAPER, no framework.
|
||||||
// The pS-usage seam: instances publish held captures; the extension folds live
|
// 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
|
// 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
|
// the prune-protection guarantee — a SAFETY property: every failure, ambiguity, or
|
||||||
// to prune (the composed proof at the bottom links prune_reconcile and shows
|
// uncertainty must FAIL-SAFE toward PROTECT (over-protection acceptable;
|
||||||
// pruneOrphans can never emit a held path), while a stale record from a deleted
|
// under-protection = deleting a maybe-used file is a data-loss bug).
|
||||||
// instance must NOT permanently block reclaim (the liveness fold).
|
|
||||||
//
|
//
|
||||||
// Covers: wire round-trip (empty / adversarial bytes), malformed -> nullopt, the
|
// Covers: wire round-trip (nonce + unioned flag, empty / adversarial bytes),
|
||||||
// publish plan's four branches (fresh key / clean replace / same-track union /
|
// malformed -> nullopt, the publish plan's branches (fresh / clean replace + skip /
|
||||||
// cross-track re-mint) + skipWrite idempotence, the liveness fold (live, dead-track,
|
// sibling union with the sticky poison flag / cross-track re-mint / undecodable heal),
|
||||||
// empty-guid fallback, de-dup), and the composed pruneOrphans exclusion proof.
|
// 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 "../src/sample_usage.h"
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -28,37 +35,49 @@ static int g_fail = 0;
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
UsageRecord makeRecord(const std::string& trackGuid,
|
UsageRecord makeRecord(const std::string& trackGuid, const std::string& nonce,
|
||||||
std::vector<UsageHold> holds) {
|
std::vector<UsageHold> holds, bool unioned = false) {
|
||||||
UsageRecord r;
|
UsageRecord r;
|
||||||
r.trackGuid = trackGuid;
|
r.trackGuid = trackGuid;
|
||||||
|
r.ownerNonce = nonce;
|
||||||
|
r.unioned = unioned;
|
||||||
r.holds = std::move(holds);
|
r.holds = std::move(holds);
|
||||||
return r;
|
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
|
} // namespace
|
||||||
|
|
||||||
// --- wire round-trip ----------------------------------------------------------
|
// --- wire round-trip ----------------------------------------------------------
|
||||||
|
|
||||||
static void testRoundTrip() {
|
static void testRoundTrip() {
|
||||||
const UsageRecord rec = makeRecord(
|
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-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);
|
const std::string wire = encodeUsageRecord(rec);
|
||||||
auto back = decodeUsageRecord(wire);
|
auto back = decodeUsageRecord(wire);
|
||||||
CHECK(back.has_value());
|
CHECK(back.has_value());
|
||||||
CHECK(*back == rec);
|
CHECK(*back == rec);
|
||||||
|
CHECK(back->unioned);
|
||||||
CHECK(encodeUsageRecord(*back) == wire); // deterministic re-encode
|
CHECK(encodeUsageRecord(*back) == wire); // deterministic re-encode
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testRoundTripEmptyHoldsAndEmptyGuid() {
|
static void testRoundTripEmptyHoldsAndEmptyGuid() {
|
||||||
// An empty-holds record is LEGAL (an instance releasing everything it held), and an
|
// 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).
|
// 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));
|
auto back = decodeUsageRecord(encodeUsageRecord(rec));
|
||||||
CHECK(back.has_value());
|
CHECK(back.has_value());
|
||||||
CHECK(back->trackGuid.empty());
|
CHECK(back->trackGuid.empty());
|
||||||
|
CHECK(back->ownerNonce.empty());
|
||||||
|
CHECK(!back->unioned);
|
||||||
CHECK(back->holds.empty());
|
CHECK(back->holds.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +85,7 @@ static void testRoundTripAdversarialBytes() {
|
|||||||
// Ids/paths carrying the wire's own metacharacters must survive whole (the whole
|
// Ids/paths carrying the wire's own metacharacters must survive whole (the whole
|
||||||
// point of length-prefixing): ':' delimiters, digits, the magic tag itself.
|
// point of length-prefixing): ':' delimiters, digits, the magic tag itself.
|
||||||
const UsageRecord rec = makeRecord(
|
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"}});
|
{UsageHold{"rsusage1-lookalike", "path with spaces/and:colons/7:x.wav"}});
|
||||||
auto back = decodeUsageRecord(encodeUsageRecord(rec));
|
auto back = decodeUsageRecord(encodeUsageRecord(rec));
|
||||||
CHECK(back.has_value());
|
CHECK(back.has_value());
|
||||||
@@ -77,15 +96,19 @@ static void testDecodeMalformed() {
|
|||||||
CHECK(!decodeUsageRecord("").has_value());
|
CHECK(!decodeUsageRecord("").has_value());
|
||||||
CHECK(!decodeUsageRecord("garbage").has_value());
|
CHECK(!decodeUsageRecord("garbage").has_value());
|
||||||
CHECK(!decodeUsageRecord("rsusage1").has_value()); // truncated after magic
|
CHECK(!decodeUsageRecord("rsusage1").has_value()); // truncated after magic
|
||||||
CHECK(!decodeUsageRecord("rsusage2" "0:1:0").has_value()); // wrong magic version
|
CHECK(!decodeUsageRecord("rsusage2" "0:0:1:01:0").has_value()); // wrong magic
|
||||||
// Truncated mid-holds: claims 2 holds, carries 1.
|
// A non-"0"/"1" unioned field is corruption -> reject whole. Hand-built wire:
|
||||||
UsageRecord one = makeRecord("{G}", {UsageHold{"a", "p.wav"}});
|
// 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);
|
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:0" "1:1"; // unioned=0 then count=1
|
||||||
const std::string needle = "1:1"; // count field for one hold
|
const std::size_t pos = wire.find(needle);
|
||||||
const std::size_t pos = wire.find(needle, std::string("rsusage1").size() + 4);
|
|
||||||
CHECK(pos != std::string::npos);
|
CHECK(pos != std::string::npos);
|
||||||
wire[pos + 2] = '2';
|
wire[pos + 5] = '2'; // count "1:1" -> "1:2"
|
||||||
CHECK(!decodeUsageRecord(wire).has_value());
|
CHECK(!decodeUsageRecord(wire).has_value());
|
||||||
// Trailing garbage after a complete record -> reject whole.
|
// Trailing garbage after a complete record -> reject whole.
|
||||||
CHECK(!decodeUsageRecord(encodeUsageRecord(one) + "x").has_value());
|
CHECK(!decodeUsageRecord(encodeUsageRecord(one) + "x").has_value());
|
||||||
@@ -94,102 +117,200 @@ static void testDecodeMalformed() {
|
|||||||
// --- publish plan --------------------------------------------------------------
|
// --- publish plan --------------------------------------------------------------
|
||||||
|
|
||||||
static void testPlanFreshKey() {
|
static void testPlanFreshKey() {
|
||||||
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "p.wav"}});
|
const UsageRecord mine = makeRecord("{T1}", "NA", {UsageHold{"a", "p.wav"}});
|
||||||
const UsagePublishPlan plan = planUsagePublish(std::nullopt, "", mine);
|
const UsagePublishPlan plan = planUsagePublish(std::nullopt, mine);
|
||||||
CHECK(!plan.remint);
|
CHECK(!plan.remint);
|
||||||
CHECK(!plan.skipWrite);
|
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() {
|
static void testPlanCleanReplaceAndSkip() {
|
||||||
// The key holds exactly what this lifetime wrote -> clean replace; released holds drop.
|
// The key holds this incarnation's own un-poisoned write (nonce match, !unioned):
|
||||||
const UsageRecord prev = makeRecord("{T1}", {UsageHold{"a", "pa.wav"},
|
// the sole-writer path. Clean replace; released holds drop.
|
||||||
UsageHold{"b", "pb.wav"}});
|
const UsageRecord prev = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"},
|
||||||
|
UsageHold{"b", "pb.wav"}});
|
||||||
const std::string prevWire = encodeUsageRecord(prev);
|
const std::string prevWire = encodeUsageRecord(prev);
|
||||||
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
const UsageRecord mine = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}});
|
||||||
const UsagePublishPlan plan = planUsagePublish(prevWire, prevWire, mine);
|
const UsagePublishPlan plan = planUsagePublish(prevWire, mine);
|
||||||
CHECK(!plan.remint);
|
CHECK(!plan.remint);
|
||||||
CHECK(!plan.skipWrite);
|
CHECK(!plan.skipWrite);
|
||||||
auto back = decodeUsageRecord(plan.wire);
|
auto back = decodeUsageRecord(plan.wire);
|
||||||
CHECK(back.has_value());
|
CHECK(back.has_value());
|
||||||
CHECK(back->holds.size() == 1); // 'b' genuinely released — NOT unioned back in
|
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).
|
// 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.skipWrite);
|
||||||
CHECK(!idle.remint);
|
CHECK(!idle.remint);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testPlanSameTrackUnion() {
|
// The review's 🔴#1 repro. Two same-track FX copies share a key and converge on
|
||||||
// First publish of a lifetime (lastPublished empty) over a same-track existing value:
|
// byte-identical hold sets; the OLD wire-equality discriminator let sibling A
|
||||||
// my own last-session record OR a same-track copy-sibling — either way UNION, never
|
// clean-replace over B's still-held path. With the in-wire per-lifetime nonce +
|
||||||
// drop (the fail-safe direction; a sibling's holds must survive my write).
|
// sticky unioned poison, every same-track collision unions and NO write of A's can
|
||||||
const UsageRecord theirs = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
// ever drop B's holds — including A's SECOND write after it re-owns the key.
|
||||||
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"b", "pb.wav"},
|
static void testSiblingCollisionNeverDropsHolds() {
|
||||||
UsageHold{"a", "pa.wav"}});
|
// 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 =
|
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);
|
CHECK(!plan.remint);
|
||||||
auto back = decodeUsageRecord(plan.wire);
|
auto back = decodeUsageRecord(plan.wire);
|
||||||
CHECK(back.has_value());
|
CHECK(back.has_value());
|
||||||
CHECK(back->trackGuid == "{T1}");
|
CHECK(holdsContainPath(back->holds, "pa.wav")); // never dropped
|
||||||
CHECK(back->holds.size() == 2); // a (existing-first) + b, de-duped
|
CHECK(back->unioned);
|
||||||
CHECK(back->holds[0].sampleId == "a");
|
|
||||||
CHECK(back->holds[1].sampleId == "b");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testPlanCrossTrackRemint() {
|
static void testPlanCrossTrackRemint() {
|
||||||
// The key holds a foreign record from ANOTHER track: this state was cloned there
|
// 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.
|
// (FX copy / track duplication) — take a fresh identity, leave theirs untouched.
|
||||||
const UsageRecord theirs = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
const UsageRecord theirs = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}});
|
||||||
const UsageRecord mine = makeRecord("{T2}", {UsageHold{"a", "pa.wav"}});
|
const UsageRecord mine = makeRecord("{T2}", "NB", {UsageHold{"a", "pa.wav"}});
|
||||||
const UsagePublishPlan plan =
|
const UsagePublishPlan plan = planUsagePublish(encodeUsageRecord(theirs), mine);
|
||||||
planUsagePublish(encodeUsageRecord(theirs), "", mine);
|
|
||||||
CHECK(plan.remint);
|
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() {
|
static void testPlanUndecodableExisting() {
|
||||||
// An undecodable existing value protects nothing — overwrite with mine.
|
// An undecodable existing value under MY key is corruption — overwrite with mine
|
||||||
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
// (the self-heal; the prune side independently aborts while it is unreadable).
|
||||||
const UsagePublishPlan plan = planUsagePublish(std::string("corrupt"), "", mine);
|
const UsageRecord mine = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}});
|
||||||
|
const UsagePublishPlan plan = planUsagePublish(std::string("corrupt"), mine);
|
||||||
CHECK(!plan.remint);
|
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 ---------------------------------------------------------------
|
// --- liveness fold ---------------------------------------------------------------
|
||||||
|
|
||||||
static void testHeldPathsLiveness() {
|
static void testHeldPathsLiveness() {
|
||||||
const std::vector<UsageRecord> records = {
|
const std::vector<UsageRecord> records = {
|
||||||
makeRecord("{LIVE}", {UsageHold{"a", "pa.wav"}}),
|
makeRecord("{LIVE}", "N1", {UsageHold{"a", "pa.wav"}}),
|
||||||
makeRecord("{DEAD}", {UsageHold{"b", "pb.wav"}}), // deleted track/instance
|
makeRecord("{DEAD}", "N2", {UsageHold{"b", "pb.wav"}}), // deleted track
|
||||||
makeRecord("", {UsageHold{"c", "pc.wav"}}), // no track context
|
makeRecord("", "N3", {UsageHold{"c", "pc.wav"}}), // no track context
|
||||||
};
|
};
|
||||||
const std::unordered_set<std::string> live = {"{LIVE}"};
|
const std::unordered_set<std::string> live = {"{LIVE}"};
|
||||||
|
|
||||||
// Live-track record counts; dead-track record is EXCLUDED (no stale false-protect);
|
// Live-track record counts; dead-track record is EXCLUDED (stale-record cleanup —
|
||||||
// empty-guid record counts while ANY instance lives (fail-safe fallback).
|
// 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);
|
const std::vector<std::string> withAny = usageHeldPaths(records, live, true);
|
||||||
CHECK(withAny.size() == 2);
|
CHECK(withAny.size() == 2);
|
||||||
CHECK(withAny[0] == "pa.wav");
|
CHECK(withAny[0] == "pa.wav");
|
||||||
CHECK(withAny[1] == "pc.wav");
|
CHECK(withAny[1] == "pc.wav");
|
||||||
|
}
|
||||||
|
|
||||||
// No instance anywhere -> empty-guid fallback closes too; only live-track survives.
|
// The review's 🔴#2 repro: records exist but ZERO instances were identified live
|
||||||
const std::vector<std::string> noAny = usageHeldPaths(records, live, false);
|
// (either every instance was genuinely deleted, or — indistinguishable — the identity
|
||||||
CHECK(noAny.size() == 1);
|
// matcher failed on every FX). The old fold dropped every record -> all held captures
|
||||||
CHECK(noAny[0] == "pa.wav");
|
// became prunable. The fail-safe net protects ALL records' paths instead.
|
||||||
|
static void testZeroIdentifiedProtectsAll() {
|
||||||
// Zero live instances at all -> nothing protected (a project whose instances were
|
const std::vector<UsageRecord> records = {
|
||||||
// all deleted cannot be permanently blocked by leftover records).
|
makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}),
|
||||||
const std::vector<std::string> none =
|
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);
|
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() {
|
static void testHeldPathsDedupAndEmptyPathSkip() {
|
||||||
const std::vector<UsageRecord> records = {
|
const std::vector<UsageRecord> records = {
|
||||||
makeRecord("{T}", {UsageHold{"a", "shared.wav"}, UsageHold{"x", ""}}),
|
makeRecord("{T}", "N1", {UsageHold{"a", "shared.wav"}, UsageHold{"x", ""}}),
|
||||||
makeRecord("{T}", {UsageHold{"b", "shared.wav"}, UsageHold{"c", "own.wav"}}),
|
makeRecord("{T}", "N2", {UsageHold{"b", "shared.wav"}, UsageHold{"c", "own.wav"}}),
|
||||||
};
|
};
|
||||||
const std::unordered_set<std::string> live = {"{T}"};
|
const std::unordered_set<std::string> live = {"{T}"};
|
||||||
const std::vector<std::string> paths = usageHeldPaths(records, live, true);
|
const std::vector<std::string> paths = usageHeldPaths(records, live, true);
|
||||||
@@ -198,6 +319,92 @@ static void testHeldPathsDedupAndEmptyPathSkip() {
|
|||||||
CHECK(paths[1] == "own.wav");
|
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 composed prune-protection proof -----------------------------------------
|
||||||
// The definition-of-done property at the pure layer: a capture held by a live instance
|
// 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
|
// 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.
|
// A live instance holds held.wav -> the union protects it; orphan.wav still reclaims.
|
||||||
const std::vector<UsageRecord> records = {
|
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::unordered_set<std::string> live = {"{T}"};
|
||||||
const std::vector<std::string> referenced =
|
const std::vector<std::string> referenced =
|
||||||
mergeReferenced(bankRefs, usageHeldPaths(records, live, true));
|
mergeReferenced(bankRefs, usageHeldPaths(records, live, true));
|
||||||
@@ -222,10 +429,23 @@ static void testInstanceHoldMakesPathUnprunable() {
|
|||||||
CHECK(orphans.size() == 1);
|
CHECK(orphans.size() == 1);
|
||||||
CHECK(orphans[0] == "orphan.wav");
|
CHECK(orphans[0] == "orphan.wav");
|
||||||
|
|
||||||
// The instance (and its track) deleted -> the record no longer counts -> held.wav
|
// Zero instances identified anywhere -> the protect-all net keeps held.wav
|
||||||
// is reclaimable again (no permanent stale-key block).
|
// un-prunable THROUGH the composed pipeline too (matcher failure must never
|
||||||
const std::vector<std::string> refsAfterDelete = mergeReferenced(
|
// 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));
|
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);
|
CHECK(pruneOrphans(present, refsAfterDelete, owned).size() == 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,11 +467,19 @@ int main() {
|
|||||||
testDecodeMalformed();
|
testDecodeMalformed();
|
||||||
testPlanFreshKey();
|
testPlanFreshKey();
|
||||||
testPlanCleanReplaceAndSkip();
|
testPlanCleanReplaceAndSkip();
|
||||||
testPlanSameTrackUnion();
|
testSiblingCollisionNeverDropsHolds();
|
||||||
|
testPlanUnionSkipOnlyWhenAlreadyPoisoned();
|
||||||
|
testPlanEmptyNonceNeverClaimsOwnership();
|
||||||
testPlanCrossTrackRemint();
|
testPlanCrossTrackRemint();
|
||||||
|
testPlanOwnRecordAfterTrackMove();
|
||||||
testPlanUndecodableExisting();
|
testPlanUndecodableExisting();
|
||||||
testHeldPathsLiveness();
|
testHeldPathsLiveness();
|
||||||
|
testZeroIdentifiedProtectsAll();
|
||||||
testHeldPathsDedupAndEmptyPathSkip();
|
testHeldPathsDedupAndEmptyPathSkip();
|
||||||
|
testTakeFxAttributedRecordIsProtected();
|
||||||
|
testUnreadableRecordAbortsPrune();
|
||||||
|
testIdentityMatcher();
|
||||||
|
|
||||||
testInstanceHoldMakesPathUnprunable();
|
testInstanceHoldMakesPathUnprunable();
|
||||||
testMergeReferencedOrderAndDedup();
|
testMergeReferencedOrderAndDedup();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user