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:
+127
-35
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "sample_usage.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <limits>
|
||||
|
||||
@@ -92,6 +93,8 @@ private:
|
||||
std::string encodeUsageRecord(const UsageRecord& rec) {
|
||||
std::string out = kMagic;
|
||||
putField(out, rec.trackGuid);
|
||||
putField(out, rec.ownerNonce);
|
||||
putField(out, rec.unioned ? "1" : "0");
|
||||
putField(out, std::to_string(rec.holds.size()));
|
||||
for (const UsageHold& h : rec.holds) {
|
||||
putField(out, h.sampleId);
|
||||
@@ -105,6 +108,12 @@ std::optional<UsageRecord> decodeUsageRecord(const std::string& wire) {
|
||||
if (!c.literal(kMagic)) return std::nullopt;
|
||||
UsageRecord rec;
|
||||
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;
|
||||
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
|
||||
@@ -122,44 +131,73 @@ std::optional<UsageRecord> decodeUsageRecord(const std::string& wire) {
|
||||
}
|
||||
|
||||
UsagePublishPlan planUsagePublish(const std::optional<std::string>& existing,
|
||||
const std::string& lastPublishedThisLifetime,
|
||||
const UsageRecord& mine) {
|
||||
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()) {
|
||||
// Fresh key — write mine.
|
||||
} else if (!lastPublishedThisLifetime.empty() &&
|
||||
*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;
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -169,10 +207,15 @@ std::vector<std::string> usageHeldPaths(
|
||||
bool anyInstanceLive) {
|
||||
std::vector<std::string> out;
|
||||
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) {
|
||||
const bool live = rec.trackGuid.empty()
|
||||
? anyInstanceLive
|
||||
: (liveTrackGuids.count(rec.trackGuid) != 0);
|
||||
const bool live = protectAll ||
|
||||
(rec.trackGuid.empty()
|
||||
? anyInstanceLive
|
||||
: (liveTrackGuids.count(rec.trackGuid) != 0));
|
||||
if (!live) continue;
|
||||
for (const UsageHold& h : rec.holds) {
|
||||
if (h.relativePath.empty()) continue;
|
||||
@@ -182,4 +225,53 @@ std::vector<std::string> usageHeldPaths(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user