fix(pS-usage): close delete-ward residuals — remint on corrupt, named abort keys, truncation protect-all, abort->protect-all set
This commit is contained in:
+12
-4
@@ -826,10 +826,18 @@ void doBankPruneFolder() {
|
||||
// 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");
|
||||
std::string msg =
|
||||
"ReaSampler prune: ABORTED -- one or more instance usage records could not "
|
||||
"be read or decoded. Nothing was deleted.\n"
|
||||
"If the owning instance is still loaded it will republish its record on the "
|
||||
"next poll tick, clearing the abort. If the instance no longer exists (the "
|
||||
"key is an orphaned corrupt record), clear it manually via ReaScript:\n"
|
||||
" reaper.SetProjExtState(0, \"reasampler\", \"<key>\", \"\")\n"
|
||||
"Offending key(s):\n";
|
||||
for (const std::string& key : report.offendingUsageKeys) {
|
||||
msg += " " + key + "\n";
|
||||
}
|
||||
ShowConsoleMsg(msg.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -317,6 +317,7 @@ struct PruneScan {
|
||||
std::vector<std::string> orphans;
|
||||
std::unordered_map<std::string, std::uint64_t> sizeByRel;
|
||||
bool abortedUnreadableUsage = false;
|
||||
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
|
||||
};
|
||||
|
||||
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
|
||||
@@ -381,8 +382,10 @@ PruneScan scanPruneOrphans(const BankBook& book, const OwnedFileManifest& owned)
|
||||
// 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.
|
||||
// nothing. The flag + key names surface the reason so the action can name each
|
||||
// offending key for operator recovery.
|
||||
scan.abortedUnreadableUsage = true;
|
||||
scan.offendingUsageKeys = usage.offendingKeys;
|
||||
return scan;
|
||||
}
|
||||
scan.orphans = pruneOrphans(
|
||||
@@ -402,7 +405,9 @@ PruneReport ReaSamplerSession::pruneDryRun() const {
|
||||
// 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).
|
||||
// The offending key names propagate so the action can name each one for recovery.
|
||||
report.abortedUnreadableUsage = scan.abortedUnreadableUsage;
|
||||
report.offendingUsageKeys = scan.offendingUsageKeys;
|
||||
return report;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,12 +70,20 @@ namespace reasampler {
|
||||
// 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).
|
||||
// * offendingUsageKeys — the exact "rsusage_<guid>" ext-state key names that
|
||||
// triggered the abort (non-empty iff abortedUnreadableUsage). Named
|
||||
// so the action can print them for operator recovery: a corrupt/
|
||||
// oversized key whose owning instance no longer exists is never
|
||||
// automatically rewritten, so the abort would be permanent without
|
||||
// a way to clear it. The operator can clear each key via ReaScript:
|
||||
// reaper.SetProjExtState(0, "reasampler", "<key>", "")
|
||||
struct PruneReport {
|
||||
std::size_t count = 0;
|
||||
std::uint64_t totalBytes = 0;
|
||||
std::vector<std::string> orphans;
|
||||
bool truncated = false;
|
||||
bool abortedUnreadableUsage = false;
|
||||
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
|
||||
};
|
||||
|
||||
// The outcome of an actual prune DELETION (Phase R, Wave 3 — R3). The prune shell fills
|
||||
|
||||
+31
-7
@@ -145,11 +145,20 @@ UsagePublishPlan planUsagePublish(const std::optional<std::string>& existing,
|
||||
}
|
||||
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.
|
||||
// Undecodable existing value under MY key: corruption (a sibling sharing
|
||||
// this key via copy always writes decodable records). REMINT rather than
|
||||
// overwrite: writing mine over the corrupt key would clear the prune-side
|
||||
// abort, but a same-key sibling B's holds would then be unprotected until
|
||||
// B publishes again. Leaving the corrupt key in place keeps the prune-side
|
||||
// abort firing (foldUsageRecords.abortPrune) so the window where B's holds
|
||||
// might be unprotected can never resolve toward delete. Mine is published
|
||||
// under the new key that remint produces.
|
||||
// NOTE (>16 MB gap): readReasamplerExtState returning nullopt for a value
|
||||
// larger than 16 MB is indistinguishable from "absent" at the publish site;
|
||||
// that narrow case takes the fresh-write branch above rather than remint.
|
||||
// Both outcomes are safe (fresh write is also correct for a truly absent key);
|
||||
// the gap is documented in the header's fail-safe list.
|
||||
plan.remint = true;
|
||||
return plan;
|
||||
}
|
||||
|
||||
@@ -235,9 +244,24 @@ UsageFoldResult foldUsageRecords(
|
||||
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).
|
||||
// must halt outright. Belt-and-braces: return the PROTECT-ALL set (all
|
||||
// readable records' paths) so the fail-safe holds even under a future
|
||||
// caller that forgets to check abortPrune before using heldPaths. The
|
||||
// abort flag is still the authoritative signal; heldPaths is the
|
||||
// maximum-protection fallback.
|
||||
result.abortPrune = true;
|
||||
// Collect EVERY path from EVERY readable record, bypassing the liveness
|
||||
// filter entirely (on abort the protected set is unknowable, so every
|
||||
// decoded hold must be included regardless of track-guid membership).
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const std::optional<UsageRecord>& r : decoded) {
|
||||
if (!r) continue;
|
||||
for (const UsageHold& h : r->holds) {
|
||||
if (h.relativePath.empty()) continue;
|
||||
if (seen.insert(h.relativePath).second)
|
||||
result.heldPaths.push_back(h.relativePath);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
records.push_back(*rec);
|
||||
|
||||
+13
-5
@@ -36,7 +36,10 @@
|
||||
// degrade toward delete);
|
||||
// * unreadable record -> ABORT the prune entirely (foldUsageRecords.abortPrune — a
|
||||
// record we cannot read may protect anything; halting deletes
|
||||
// nothing).
|
||||
// nothing). Residual: readReasamplerExtState returning nullopt
|
||||
// for a >16 MB value is indistinguishable from "absent" at the
|
||||
// publish site — that narrow case takes the fresh-write branch
|
||||
// (not remint), noted here for completeness.
|
||||
//
|
||||
// -- Liveness (no stale-key false-protect, no false-delete) --------------------
|
||||
//
|
||||
@@ -170,10 +173,15 @@ struct UsagePublishPlan {
|
||||
// THIS lifetime's nonce; `mine.unioned` is ignored (the plan computes the written
|
||||
// flag). Branches, in order:
|
||||
// * existing absent/empty -> write mine (unioned=false — sole known writer).
|
||||
// * existing undecodable -> write mine, unioned=false (this is MY key — a
|
||||
// sibling sharing it via copy always writes decodable records, so an undecodable
|
||||
// value is corruption; overwriting restores correct protection for my holds, and
|
||||
// the prune side independently ABORTS while an unreadable record is present).
|
||||
// * existing undecodable -> REMINT (mine, unioned=false, under a fresh key)
|
||||
// rather than overwriting the corrupt key: overwriting would clear the prune-side
|
||||
// abort, leaving a same-key sibling's holds unprotected until it republishes.
|
||||
// Leaving the corrupt key in place keeps the prune-side abort (foldUsageRecords)
|
||||
// firing so no delete-ward window opens. The sibling writes its own decodable
|
||||
// record on the next publish tick; the corrupt key is eventually evicted once no
|
||||
// live instance references it. Narrow gap: a >16 MB value reads back as nullopt
|
||||
// (indistinguishable from absent), so it takes the fresh-write branch rather than
|
||||
// remint — both outcomes are safe; the gap is noted in the header's fail-safe list.
|
||||
// * nonce match AND !unioned -> clean replace (sole writer, provably my content;
|
||||
// released holds drop); skipWrite when
|
||||
// byte-identical (idle reload tick).
|
||||
|
||||
+27
-8
@@ -69,8 +69,9 @@ using FxParmGetter =
|
||||
|
||||
// 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
|
||||
// renamed instance may keep its original_name; fx_ident carries the module path — the
|
||||
// primary identification net is the module filename base via fx_ident, which holds even
|
||||
// after a user renames the FX instance). 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
|
||||
@@ -83,9 +84,17 @@ bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId,
|
||||
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
|
||||
if (countStr.empty()) return false; // not a container; no children to miss
|
||||
if (depth <= 0) {
|
||||
// This node IS a container but we have exhausted our descent budget. We cannot
|
||||
// prove that none of its children is a ReaSampler 9000 instance — treat the
|
||||
// incomplete walk as a positive identification (the protect direction). This is
|
||||
// defense-in-depth: kMaxContainerDepth = 32 should prevent reaching this branch
|
||||
// in any real project, but if it IS reached the fail-safe fires rather than
|
||||
// silently missing a live nested instance.
|
||||
return true;
|
||||
}
|
||||
const int n = std::atoi(countStr.c_str());
|
||||
for (int k = 0; k < n; ++k) {
|
||||
const std::string item =
|
||||
@@ -98,7 +107,10 @@ bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId,
|
||||
return false;
|
||||
}
|
||||
|
||||
constexpr int kMaxContainerDepth = 8;
|
||||
// Raised from 8 to 32 (defense in depth against truncation). Real-world FX containers
|
||||
// are typically 2–4 levels deep; 32 is unreachable in practice while remaining finite.
|
||||
// Even at 32, the truncation→protect-all guard below is the primary protection.
|
||||
constexpr int kMaxContainerDepth = 32;
|
||||
|
||||
std::string trackFxParm(MediaTrack* tr, int fxId, const char* parm) {
|
||||
char buf[2048] = {0};
|
||||
@@ -183,6 +195,7 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
|
||||
// undecodable -> the pure fold ABORTS the prune.
|
||||
std::vector<std::string> usageKeys;
|
||||
{
|
||||
const std::string prefix = kProjExtUsageKeyPrefix; // hoisted: one alloc, not N
|
||||
char keyBuf[256];
|
||||
for (int idx = 0;; ++idx) {
|
||||
keyBuf[0] = '\0';
|
||||
@@ -190,7 +203,6 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
|
||||
static_cast<int>(sizeof(keyBuf)), nullptr, 0))
|
||||
break;
|
||||
const std::string key(keyBuf);
|
||||
const std::string prefix = kProjExtUsageKeyPrefix;
|
||||
if (key.compare(0, prefix.size(), prefix) == 0) usageKeys.push_back(key);
|
||||
}
|
||||
}
|
||||
@@ -198,13 +210,17 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
|
||||
|
||||
std::vector<std::optional<UsageRecord>> decoded;
|
||||
decoded.reserve(usageKeys.size());
|
||||
for (const std::string& key : usageKeys) {
|
||||
for (std::size_t ki = 0; ki < usageKeys.size(); ++ki) {
|
||||
const std::string& key = usageKeys[ki];
|
||||
const std::optional<std::string> value = readExtStateValue(proj, key.c_str());
|
||||
if (!value) {
|
||||
decoded.push_back(std::nullopt); // unreadable -> abort (pure fold)
|
||||
result.offendingKeys.push_back(key);
|
||||
continue;
|
||||
}
|
||||
decoded.push_back(decodeUsageRecord(*value)); // undecodable -> nullopt -> abort
|
||||
const std::optional<UsageRecord> rec = decodeUsageRecord(*value);
|
||||
if (!rec) result.offendingKeys.push_back(key);
|
||||
decoded.push_back(rec); // undecodable nullopt -> abort
|
||||
}
|
||||
|
||||
// 2. Enumerate live ReaSampler 9000 hosts. One channel-frozen needle set drives
|
||||
@@ -250,6 +266,9 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
|
||||
const UsageFoldResult fold = foldUsageRecords(decoded, liveTrackGuids, anyLive);
|
||||
result.abortPrune = fold.abortPrune;
|
||||
result.heldPaths = fold.heldPaths;
|
||||
// offendingKeys already populated above (unreadable + undecodable entries);
|
||||
// clear it on success so callers see it only when abortPrune is set.
|
||||
if (!result.abortPrune) result.offendingKeys.clear();
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -37,12 +37,18 @@
|
||||
namespace reasampler {
|
||||
|
||||
// The scan outcome. When abortPrune is true a present rsusage_* record could not be
|
||||
// read or decoded — the caller MUST halt the prune (delete nothing); heldPaths is then
|
||||
// meaningless (left empty). Otherwise heldPaths is every project-relative path held by
|
||||
// read or decoded — the caller MUST halt the prune (delete nothing). offendingKeys
|
||||
// names the exact "rsusage_<guid>" keys that triggered the abort so the action can
|
||||
// print them for operator recovery (clear via ReaScript:
|
||||
// reaper.SetProjExtState(0, "reasampler", "<key>", "")
|
||||
// for each offending key). heldPaths on abort is the protect-all set (every readable
|
||||
// record's paths) — meaningful only as a belt-and-braces fallback; the abort flag is
|
||||
// the authoritative signal. Otherwise heldPaths is every project-relative path held by
|
||||
// a live ReaSampler 9000 instance, de-duped, in record order — empty in the common
|
||||
// no-records case (the FX enumeration is skipped entirely).
|
||||
struct UsageScanResult {
|
||||
bool abortPrune = false;
|
||||
std::vector<std::string> offendingKeys; // non-empty iff abortPrune
|
||||
std::vector<std::string> heldPaths;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user