From 5886ae14568edf783b6f70d26ac6bcdbb47d0bfb Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 12:56:36 -0400 Subject: [PATCH 1/3] =?UTF-8?q?pS-usage:=20captures=20held=20by=20live=20R?= =?UTF-8?q?eaSampler=209000=20instances=20are=20un-prunable=20=E2=80=94=20?= =?UTF-8?q?instances=20publish=20usage=5F=20ext-state=20records=20(C?= =?UTF-8?q?omponentState=20v11),=20prune=20unions=20live=20holds=20into=20?= =?UTF-8?q?referenced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 26 ++- src/ext_keys.h | 19 +++ src/persist.cpp | 13 +- src/persist.h | 6 +- src/prune_reconcile.cpp | 14 ++ src/prune_reconcile.h | 13 ++ src/sample_usage.cpp | 185 ++++++++++++++++++++++ src/sample_usage.h | 151 ++++++++++++++++++ src/usage_scan.cpp | 240 ++++++++++++++++++++++++++++ src/usage_scan.h | 41 +++++ src/vst/reaper_bridge.cpp | 42 +++++ src/vst/reaper_bridge.h | 29 ++++ src/vst/reasampler_processor.cpp | 79 +++++++++ src/vst/reasampler_processor.h | 24 +++ src/vst/sample_map.cpp | 13 ++ src/vst/sample_map.h | 27 +++- tests/test_prune_reconcile.cpp | 31 ++++ tests/test_sample_map.cpp | 75 +++++++++ tests/test_sample_usage.cpp | 264 +++++++++++++++++++++++++++++++ 19 files changed, 1282 insertions(+), 10 deletions(-) create mode 100644 src/sample_usage.cpp create mode 100644 src/sample_usage.h create mode 100644 src/usage_scan.cpp create mode 100644 src/usage_scan.h create mode 100644 tests/test_sample_usage.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f35f569..a0e8fa2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -337,6 +337,17 @@ target_include_directories(provenance PUBLIC src) add_library(assignment_request STATIC src/assignment_request.cpp) target_include_directories(assignment_request PUBLIC src) +# --------------------------------------------------------------------------- +# 2j'') Pure sample_usage library — NO REAPER, NO SWELL, NO VST3. The pS-usage seam: +# the per-instance usage record the INSTRUMENT writes ("usage_" ext-state, +# 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), +# and the liveness fold (which records count against the live FX enumeration). +# Linked by BOTH artifacts — the mirror of assignment_request, reversed direction. +# --------------------------------------------------------------------------- +add_library(sample_usage STATIC src/sample_usage.cpp) +target_include_directories(sample_usage PUBLIC src) + # --------------------------------------------------------------------------- # 2l) Pure drag_out library — NO REAPER, NO SWELL, NO OS/OLE. The Milestone 11 # native-OS-drag-out decision core: the gesture-boundary decision (drag state + @@ -708,6 +719,14 @@ add_executable(assignment_request_tests tests/test_assignment_request.cpp) target_link_libraries(assignment_request_tests PRIVATE assignment_request) add_test(NAME assignment_request_tests COMMAND assignment_request_tests) +# sample_usage (pS-usage): the instance-usage wire + publish plan + liveness fold. The +# tests are the prune-protection proof at the pure layer: a capture held by a live +# instance lands in the referenced union and pruneOrphans can never emit it (links +# prune_reconcile for the composed proof). +add_executable(sample_usage_tests tests/test_sample_usage.cpp) +target_link_libraries(sample_usage_tests PRIVATE sample_usage prune_reconcile) +add_test(NAME sample_usage_tests COMMAND sample_usage_tests) + # sampler_core: the S3 heart. Links ONLY sampler_core (+ its peaks dep) — NEITHER the # VST3 SDK nor the REAPER SDK — which is the structural proof of the plain-data # boundary (a VST3/REAPER type in the core would fail to compile/link here). @@ -1016,8 +1035,9 @@ add_library(reaper_reasampler MODULE src/tooltip.cpp src/card_meta.cpp src/card_drag.cpp + src/usage_scan.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' @@ -1173,11 +1193,13 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # knob_deck + curve_popup + master_gain (Wave B FB1, r11): the pure deck layout/hit-test, # the curve-popup sheet geometry, and the master-gain taper the recomposed Sample face # draws + routes against (master_gain also rides in via sample_map for the v8 wire cap). + # sample_usage (pS-usage): the usage-record wire + publish plan the processor's + # reloadInstrument publishes through the bridge (the one sanctioned VST-side write). target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal sample_map capture_paths embed_strip app_version capture_browser keyboard_strip waveform_view bank_sync browser_scroll note_entry param_slider theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit - knob_deck curve_popup master_gain) + knob_deck curve_popup master_gain sample_usage) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/ext_keys.h b/src/ext_keys.h index 48eb03e..9b6b291 100644 --- a/src/ext_keys.h +++ b/src/ext_keys.h @@ -69,4 +69,23 @@ inline constexpr const char* kProjExtBankGenKey = "bank_generation"; // changing this spelling strands any pending request an already-shipped instrument watches. inline constexpr const char* kProjExtAssignKey = "assign_request"; +// The pS-usage PER-INSTANCE USAGE-RECORD key prefix. The INSTRUMENT writes one key per +// instance — "usage_" — carrying the sample_usage wire record of every +// 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 +// 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/ +// 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- +// 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 = "usage_"; + +// 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). +inline std::string usageKeyFor(const std::string& instanceGuid) { + return std::string(kProjExtUsageKeyPrefix) + instanceGuid; +} + } // namespace reasampler diff --git a/src/persist.cpp b/src/persist.cpp index e1f6e09..c7f77e1 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -96,6 +96,7 @@ #include "app_version.h" #include "capture_paths.h" #include "prune_reconcile.h" +#include "usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`) #include "vst/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader) #define REAPERAPI_MINIMAL @@ -359,9 +360,17 @@ PruneScan scanPruneOrphans(const BankBook& book, const OwnedFileManifest& owned) // The decision lives in the pure core — read-only inputs from the book and manifest. // referencedPaths() unions across the whole book (pool included); owned().paths() is - // the manifest set. This shell only enumerates, resolves, and stats. + // the manifest set. pS-usage: the referenced set additionally unions every LIVE + // ReaSampler 9000 instance's held captures (usage_scan reads the per-instance + // usage_* records + the live FX enumeration; sample_usage decides liveness) — a + // capture 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, + // and stats. scan.bankDirAbs = bankDir; - scan.orphans = pruneOrphans(present, book.referencedPaths(), owned.paths()); + scan.orphans = pruneOrphans( + present, mergeReferenced(book.referencedPaths(), liveInstanceHeldPaths(proj)), + owned.paths()); return scan; } diff --git a/src/persist.h b/src/persist.h index 6b67f56..5bae09a 100644 --- a/src/persist.h +++ b/src/persist.h @@ -184,7 +184,11 @@ public: // relative machinery the index/persist use — never a stale absolute path, so it is // correct across a Save-As relocation), spells every enumerated entry with the index's // own convention (bankRelativeForName — byte-identical to the capture path's spelling), - // and feeds the R1 pure core with (present, book().referencedPaths(), owned().paths()). + // and feeds the R1 pure core with (present, referenced, owned().paths()) where + // `referenced` = book().referencedPaths() ∪ every LIVE ReaSampler 9000 instance's + // held captures (pS-usage: usage_scan reads the per-instance usage_* ext-state + // 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. // Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file // 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 diff --git a/src/prune_reconcile.cpp b/src/prune_reconcile.cpp index 2d79670..d605aad 100644 --- a/src/prune_reconcile.cpp +++ b/src/prune_reconcile.cpp @@ -33,6 +33,20 @@ std::vector pruneOrphans(const std::vector& present, return orphans; } +std::vector mergeReferenced(const std::vector& primary, + const std::vector& extra) { + std::vector merged; + merged.reserve(primary.size() + extra.size()); + std::unordered_set seen; + for (const std::string& p : primary) { + if (seen.insert(p).second) merged.push_back(p); + } + for (const std::string& p : extra) { + if (seen.insert(p).second) merged.push_back(p); + } + return merged; +} + PruneReport buildPruneReport( const std::vector& orphans, const std::unordered_map& sizeByPath, diff --git a/src/prune_reconcile.h b/src/prune_reconcile.h index c65ce3a..b4d80ca 100644 --- a/src/prune_reconcile.h +++ b/src/prune_reconcile.h @@ -107,6 +107,19 @@ std::vector pruneOrphans(const std::vector& present, const std::vector& referenced, const std::vector& owned); +// Union two referenced-path sets into one (pS-usage): the bank's own referencedPaths() +// PLUS the paths held by live ReaSampler 9000 instances (sample_usage::usageHeldPaths). +// Order-preserving (`primary` first, then the `extra` paths not already present), +// exact-string de-dup — the same comparison convention as everything above, so feeding +// the result to pruneOrphans keeps the `− referenced` guardrail byte-exact. A path held +// ONLY by an instance (e.g. its bank entry was deleted while the instance kept its v10 +// ref) is protected exactly like a bank-referenced one. +// +// Pure: no I/O, no REAPER. Kept here (not in the shells) so the "instance usage makes a +// file un-prunable" property is provable at the prune layer itself. +std::vector mergeReferenced(const std::vector& primary, + const std::vector& extra); + // Tallies a dry-run PruneReport from a computed orphan set and a per-path size lookup. // PURE (no I/O): the shell does the folder stat and passes the sizes in `sizeByPath`; // this owns the count / byte-sum / display-truncation decision so it is unit-testable. diff --git a/src/sample_usage.cpp b/src/sample_usage.cpp new file mode 100644 index 0000000..669d956 --- /dev/null +++ b/src/sample_usage.cpp @@ -0,0 +1,185 @@ +// sample_usage.cpp — see sample_usage.h. Pure: standard library only. + +#include "sample_usage.h" + +#include +#include + +namespace reasampler { + +namespace { + +constexpr const char* kMagic = "rsusage1"; + +// Append one length-prefixed field: ':' . The same wire idiom as +// assignment_request / provenance — one grammar across every ext-state seam. +void putField(std::string& out, const std::string& field) { + out += std::to_string(field.size()); + out += ':'; + out += field; +} + +// Bounds-checked cursor over the encoded string (the assignment_request Cursor, trimmed +// to the two field kinds this record needs). A short read latches ok_ false. +class Cursor { +public: + explicit Cursor(const std::string& s) : s_(s) {} + + bool ok() const { return ok_; } + bool atEnd() const { return pos_ >= s_.size(); } + + bool field(std::string& out) { + if (!ok_) return false; + const std::size_t colon = s_.find(':', pos_); + if (colon == std::string::npos) return fail(); + if (colon == pos_) return fail(); // empty length token + if (colon - pos_ > 20u) return fail(); // SIZE_MAX is 20 decimal digits + std::size_t len = 0; + for (std::size_t i = pos_; i < colon; ++i) { + const char c = s_[i]; + if (c < '0' || c > '9') return fail(); + const std::size_t digit = static_cast(c - '0'); + if (len > (std::numeric_limits::max() - digit) / 10u) + return fail(); + len = len * 10u + digit; + } + const std::size_t start = colon + 1; + if (start > s_.size() || len > s_.size() - start) return fail(); + out.assign(s_, start, len); + pos_ = start + len; + return true; + } + + // Consumes an exact literal at the cursor (the magic tag). Fails if absent. + bool literal(const char* lit) { + if (!ok_) return false; + std::size_t i = 0; + for (; lit[i] != '\0'; ++i) { + if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail(); + } + pos_ += i; + return true; + } + + // A length-prefixed unsigned decimal (the hold count). Fails on empty, non-digit, + // or a value past a sane ceiling (a record cannot hold more entries than bytes). + bool fieldCount(std::size_t& out) { + std::string f; + if (!field(f)) return false; + if (f.empty() || f.size() > 10u) return fail(); + std::size_t v = 0; + for (const char c : f) { + if (c < '0' || c > '9') return fail(); + v = v * 10u + static_cast(c - '0'); + } + out = v; + return true; + } + +private: + bool fail() { + ok_ = false; + return false; + } + + const std::string& s_; + std::size_t pos_ = 0; + bool ok_ = true; +}; + +} // namespace + +std::string encodeUsageRecord(const UsageRecord& rec) { + std::string out = kMagic; + putField(out, rec.trackGuid); + putField(out, std::to_string(rec.holds.size())); + for (const UsageHold& h : rec.holds) { + putField(out, h.sampleId); + putField(out, h.relativePath); + } + return out; +} + +std::optional decodeUsageRecord(const std::string& wire) { + Cursor c(wire); + if (!c.literal(kMagic)) return std::nullopt; + UsageRecord rec; + if (!c.field(rec.trackGuid)) return std::nullopt; + 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 + // provably bogus — reject before looping rather than iterating a crafted huge count. + if (count > wire.size() / 4u + 1u) return std::nullopt; + rec.holds.reserve(count); + for (std::size_t i = 0; i < count; ++i) { + UsageHold h; + if (!c.field(h.sampleId)) return std::nullopt; + if (!c.field(h.relativePath)) return std::nullopt; + rec.holds.push_back(std::move(h)); + } + if (!c.ok() || !c.atEnd()) return std::nullopt; // trailing garbage -> reject whole + return rec; +} + +UsagePublishPlan planUsagePublish(const std::optional& existing, + const std::string& lastPublishedThisLifetime, + const UsageRecord& mine) { + UsagePublishPlan plan; + plan.wire = encodeUsageRecord(mine); + + 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 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; +} + +std::vector usageHeldPaths( + const std::vector& records, + const std::unordered_set& liveTrackGuids, + bool anyInstanceLive) { + std::vector out; + std::unordered_set seen; + for (const UsageRecord& rec : records) { + const bool live = rec.trackGuid.empty() + ? anyInstanceLive + : (liveTrackGuids.count(rec.trackGuid) != 0); + if (!live) continue; + for (const UsageHold& h : rec.holds) { + if (h.relativePath.empty()) continue; + if (seen.insert(h.relativePath).second) out.push_back(h.relativePath); + } + } + return out; +} + +} // namespace reasampler diff --git a/src/sample_usage.h b/src/sample_usage.h new file mode 100644 index 0000000..5f1f2c2 --- /dev/null +++ b/src/sample_usage.h @@ -0,0 +1,151 @@ +#pragma once +// 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 +// SampleRefs — sample ids + project-relative paths) to a per-instance project ext-state +// key ("usage_", 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 +// 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 +// delete it. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO VST3, NO SWELL, +// NO vendor/ includes. Standard library only. The mirror of assignment_request (the +// other VST<->extension ext-state wire): the wire format AND the two safety-critical +// decisions (what to write on publish, which records count at prune time) live here so +// they are provable without a DAW. The shells only move strings. +// +// -- The data-ownership boundary (load-bearing) ------------------------------- +// +// The INSTRUMENT writes usage keys; the EXTENSION reads them. This is the ONE sanctioned +// instrument->ext-state write (Daniel's ruling: "if that means the VST writes to the +// 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, +// 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. +// +// -- Liveness (no stale-key false-protect, no false-delete) -------------------- +// +// A usage record must protect exactly the captures of instances that still EXIST. Two +// rejected designs shape the rules below: +// * NO teardown clearing. The obvious "clear my key in terminate()" is WRONG here: +// REAPER destroys the plugin instance when an FX is set OFFLINE — including the +// extension's own Design View CPU-park (per-FX offline on inactive-mode tracks). A +// terminate-time clear would strip the record of an instance that still exists in +// the project, opening a prune-deletes-a-used-file window. Records are therefore +// never cleared by the instrument; staleness is resolved by the EXTENSION at read +// time against the live FX enumeration. +// * NO challenge/response. Instances only poll ext-state on the EDITOR's UI timer +// (pollBankSync); a closed-editor instance could never answer a prune-time +// challenge, and its holds would be false-deleted. Publishing is therefore EAGER +// (on load + on every play-set change via reloadInstrument), and liveness is +// decided extension-side. +// +// The liveness rule (usageHeldPaths): a record counts iff the track it was published +// from still exists AND that track still hosts at least one ReaSampler 9000 FX +// 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 +// 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 +// hosts a sibling 9000 keeps its record alive (false-PROTECT only — prune skips a file +// it could have reclaimed; never the delete direction). Bounded, documented, accepted. +// +// -- Identity & the copy problem (planUsagePublish) ----------------------------- +// +// 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 +// state byte-for-byte), so two live instances can wake up sharing one key. The publish +// plan resolves every collision in the fail-safe direction: +// * existing value == what THIS instance wrote this lifetime -> clean replace (the +// normal single-owner path; holds the instance released genuinely drop). +// * existing value is foreign but from the SAME track -> UNION of holds (a same-track +// copy; neither sibling's holds may be dropped — over-protects until the next clean +// replace, never under-protects). +// * existing value is foreign from a DIFFERENT track -> RE-MINT (a cross-track copy +// 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). + +#include +#include +#include +#include + +namespace reasampler { + +// One held capture: the bank sample id (attribution/debugging) + the project-relative +// WAV path (the prune-protection payload — compared by EXACT string against the prune +// core's `present` spelling, which both sides source from the same bank-blob spelling). +struct UsageHold { + std::string sampleId; + std::string relativePath; + + bool operator==(const UsageHold& o) const { + return sampleId == o.sampleId && relativePath == o.relativePath; + } +}; + +// 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 +// 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 { + std::string trackGuid; + std::vector holds; + + bool operator==(const UsageRecord& o) const { + return trackGuid == o.trackGuid && holds == o.holds; + } +}; + +// Encode a usage record to the wire string. Length-prefixed fields behind a magic tag +// ("rsusage1"), the same idiom as assignment_request / provenance, so arbitrary bytes +// in a GUID or path round-trip whole. Deterministic. +// +// FORMAT: "rsusage1" ':' ':' +// then per hold: ':' ':' +std::string encodeUsageRecord(const UsageRecord& rec); + +// Parse a wire string produced by encodeUsageRecord. std::nullopt on malformed / +// truncated / trailing-garbage input (never UB, never a partial value). The extension +// treats an undecodable record as absent — it can protect nothing it cannot read. +std::optional decodeUsageRecord(const std::string& wire); + +// 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 +// on another track: the caller must mint a fresh instance GUID and +// write under the NEW key, leaving the existing record untouched. +// * skipWrite — true when the write would be byte-identical to what this instance +// already wrote this lifetime (idle reload tick) — skip the ext-state +// churn entirely. +// * wire — the encoded value to write (mine, or the same-track union). +struct UsagePublishPlan { + bool remint = false; + bool skipWrite = false; + std::string wire; +}; + +// Decide what to write for `mine` given the key's current value and what this instance +// last wrote THIS LIFETIME (empty string = nothing yet this lifetime — a fresh load; +// the existing value is then this instance's own persisted record from the last +// session, OR a copy-source's record: same-track -> union, other-track -> remint). +// * existing absent or undecodable -> write mine. +// * existing == lastPublishedThisLifetime -> write mine (clean replace). +// * existing.trackGuid == mine.trackGuid -> write union(existing.holds, mine.holds) +// (existing-first order, de-duped). +// * else -> remint = true, write mine (new key). +UsagePublishPlan planUsagePublish(const std::optional& existing, + const std::string& lastPublishedThisLifetime, + const UsageRecord& mine); + +// The prune-side fold: every project-relative path held by a LIVE instance, de-duped, +// in (record, hold) input order. A record counts iff +// * 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 +// * its trackGuid is empty and `anyInstanceLive` is true (the fail-safe fallback for +// a record published without a resolvable track context). +// Holds with an empty relativePath are skipped (nothing to protect). +std::vector usageHeldPaths( + const std::vector& records, + const std::unordered_set& liveTrackGuids, + bool anyInstanceLive); + +} // namespace reasampler diff --git a/src/usage_scan.cpp b/src/usage_scan.cpp new file mode 100644 index 0000000..b73be1a --- /dev/null +++ b/src/usage_scan.cpp @@ -0,0 +1,240 @@ +// usage_scan.cpp — see usage_scan.h. The REAPER reads behind the pS-usage prune +// protection; every decision is in the pure sample_usage module, this TU only reads. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers +// (CLAUDE.md §contract). Every REAPER symbol used here is verified against +// vendor/reaper-sdk/sdk/reaper_plugin_functions.h: +// * EnumProjExtState(proj, extname, idx, keyOut, sz, valOut, sz) -> bool (~1272) +// * GetProjExtState(proj, extname, key, valOut, sz) -> int (~2591) +// * CountTracks / GetTrack / GetMasterTrack (track scan) +// * TrackFX_GetCount(MediaTrack*) / TrackFX_GetRecCount(MediaTrack*) (~7283/7570) +// * TrackFX_GetNamedConfigParm(MediaTrack*, int, parm, buf, sz) -> bool (~7377) +// * CountMediaItems / GetMediaItem (~423/1964) +// * CountTakes(MediaItem*) / GetMediaItemTake(MediaItem*, int) (~471/2029) +// * GetMediaItemTrack(MediaItem*) (~2133) +// * TakeFX_GetCount / TakeFX_GetNamedConfigParm (~6710/6774) +// * guidToString (via track_guid::guidString) + +#include "usage_scan.h" + +#include +#include +#include +#include +#include + +#include "app_version.h" // vstPluginName (channel display-name fallback match) +#include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix +#include "instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex +#include "sample_usage.h" // decodeUsageRecord, usageHeldPaths (the pure decisions) +#include "track_guid.h" // guidString — the ONE canonical GUID key formatter + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjExtState +#define REAPERAPI_WANT_GetProjExtState +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetMasterTrack +#define REAPERAPI_WANT_TrackFX_GetCount +#define REAPERAPI_WANT_TrackFX_GetRecCount +#define REAPERAPI_WANT_TrackFX_GetNamedConfigParm +#define REAPERAPI_WANT_CountMediaItems +#define REAPERAPI_WANT_GetMediaItem +#define REAPERAPI_WANT_CountTakes +#define REAPERAPI_WANT_GetMediaItemTake +#define REAPERAPI_WANT_GetMediaItemTrack +#define REAPERAPI_WANT_TakeFX_GetCount +#define REAPERAPI_WANT_TakeFX_GetNamedConfigParm +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +std::string toUpperAscii(const std::string& s) { + std::string out = s; + for (char& c : out) + c = static_cast(std::toupper(static_cast(c))); + return out; +} + +// Does this FX identity string name a ReaSampler 9000 of THIS channel? Primary match: +// 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) { + char buf[2048] = {0}; + if (!TrackFX_GetNamedConfigParm(tr, fxId, parm, buf, static_cast(sizeof(buf)))) + return {}; + return std::string(buf); +} + +// True if any FX in the (possibly container-nested) sub-chain rooted at `fxId` is a +// ReaSampler 9000. Containers are walked via the documented container_count / +// container_item.X addressing (v7.06+); `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 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 +// (index | 0x1000000), containers recursively. +bool trackHasInstance(MediaTrack* tr, const std::string& uidHexUpper, + const std::string& nameUpper) { + constexpr int kMaxContainerDepth = 8; + const int n = TrackFX_GetCount(tr); + for (int i = 0; i < n; ++i) { + if (trackFxSubtreeHasInstance(tr, i, uidHexUpper, nameUpper, kMaxContainerDepth)) + return true; + } + const int rec = TrackFX_GetRecCount(tr); + for (int i = 0; i < rec; ++i) { + if (trackFxSubtreeHasInstance(tr, 0x1000000 + i, uidHexUpper, nameUpper, + kMaxContainerDepth)) + return true; + } + return false; +} + +// 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 +// take). No container recursion here: take chains are queried flat, and a sampler +// nested in a take-FX container is exotic enough that the empty-trackGuid any-instance +// fallback (sample_usage liveness rule) is the documented safety net. +bool itemHasInstance(MediaItem* item, const std::string& uidHexUpper, + const std::string& nameUpper) { + const int takes = CountTakes(item); + for (int t = 0; t < takes; ++t) { + MediaItem_Take* take = GetMediaItemTake(item, t); + if (!take) continue; + const int n = TakeFX_GetCount(take); + for (int i = 0; i < n; ++i) { + char buf[2048] = {0}; + if (TakeFX_GetNamedConfigParm(take, i, "fx_ident", buf, + static_cast(sizeof(buf))) && + identityMatches(buf, uidHexUpper, nameUpper)) + return true; + } + } + return false; +} + +// 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 +// record protects nothing, which is the DANGEROUS direction here. Empty on absence. +std::string readExtStateValue(ReaProject* proj, const char* key) { + for (int cap = 1 << 12; cap <= (1 << 24); cap <<= 2) { + std::vector buf(static_cast(cap), '\0'); + const int rv = GetProjExtState(proj, kProjExtNamespace(), key, buf.data(), cap); + if (rv <= 0) return {}; + std::string s(buf.data()); + if (static_cast(s.size()) + 1 < cap) return s; + // else possibly truncated -> grow and retry + } + return {}; +} + +} // namespace + +std::vector liveInstanceHeldPaths(void* projOpaque) { + ReaProject* proj = static_cast(projOpaque); + + // 1. Enumerate the usage_* keys and decode each record. Key names first (values via + // the growing reader — EnumProjExtState's fixed val buffer could truncate a large + // record, and a truncated record decodes to nothing = protects nothing). + std::vector usageKeys; + { + char keyBuf[256]; + for (int idx = 0;; ++idx) { + keyBuf[0] = '\0'; + if (!EnumProjExtState(proj, kProjExtNamespace(), idx, keyBuf, + static_cast(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); + } + } + std::vector records; + 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 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 + // every match; a track needs only ONE instance to keep all its records live. + const std::string uidHexUpper = toUpperAscii(vstClassIdHex()); + const std::string nameUpper = toUpperAscii(vstPluginName()); + std::unordered_set liveTrackGuids; + bool anyLive = false; + + if (MediaTrack* master = GetMasterTrack(proj)) { + if (trackHasInstance(master, uidHexUpper, nameUpper)) { + liveTrackGuids.insert(guidString(master)); + anyLive = true; + } + } + const int trackCount = CountTracks(proj); + for (int i = 0; i < trackCount; ++i) { + MediaTrack* tr = GetTrack(proj, i); + if (!tr) continue; + if (trackHasInstance(tr, uidHexUpper, nameUpper)) { + liveTrackGuids.insert(guidString(tr)); + anyLive = true; + } + } + // Take-FX instances: attributed to the owning track (the VST-side getReaperParent(1) + // resolves the same track), and they set anyLive for the empty-guid fallback. + const int itemCount = CountMediaItems(proj); + for (int i = 0; i < itemCount; ++i) { + MediaItem* item = GetMediaItem(proj, i); + if (!item) continue; + if (itemHasInstance(item, uidHexUpper, nameUpper)) { + if (MediaTrack* tr = GetMediaItemTrack(item)) { + liveTrackGuids.insert(guidString(tr)); + } + anyLive = true; + } + } + + // 3. The pure liveness fold decides which records count. + return usageHeldPaths(records, liveTrackGuids, anyLive); +} + +} // namespace reasampler diff --git a/src/usage_scan.h b/src/usage_scan.h new file mode 100644 index 0000000..4f93267 --- /dev/null +++ b/src/usage_scan.h @@ -0,0 +1,41 @@ +#pragma once +// 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: +// which project-relative bank paths are held by a LIVE ReaSampler 9000 instance? +// +// Three reads, no writes (the prune scan's READ-ONLY contract holds): +// 1. Enumerate every "usage_" key in the "reasampler" ext-state namespace +// (EnumProjExtState) and decode each record (sample_usage wire). +// 2. Enumerate every ReaSampler 9000 FX instance in the project — all tracks +// (master included), normal + record/input chains, FX containers recursively, and +// take FX — matching by fx_ident containing this channel's VST3 class-UID hex +// (instrument_drop::vstClassIdHex, the same frozen constants the factory +// registers) with the channel display name as a fallback match. OFFLINE FX are +// included: chain enumeration is chunk-level, so a Design-View-parked instance +// still protects its holds (the reason the instrument never clears its own key — +// see sample_usage.h). +// 3. Fold with the pure liveness rule (sample_usage::usageHeldPaths): a record counts +// iff its publishing track still hosts >= 1 instance (or, for a record with no +// track context, iff any instance exists at all). +// +// The result feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans, so +// `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. +// +// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). The +// header stays REAPER-free (`proj` is the opaque ReaProject* the persist seam already +// passes around as void*). + +#include +#include + +namespace reasampler { + +// Every project-relative path held by a live ReaSampler 9000 instance in `proj` +// (nullptr = active project), de-duped, in record order. Empty when no usage records +// exist (the common no-instances case — the FX enumeration is skipped entirely). +// READ-ONLY: no ext-state write, no project mutation. +std::vector liveInstanceHeldPaths(void* proj); + +} // namespace reasampler diff --git a/src/vst/reaper_bridge.cpp b/src/vst/reaper_bridge.cpp index 5df2429..403337e 100644 --- a/src/vst/reaper_bridge.cpp +++ b/src/vst/reaper_bridge.cpp @@ -42,6 +42,9 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) { getProjExtState_ = nullptr; enumProjExtState_ = nullptr; enumProjects_ = nullptr; + setProjExtState_ = nullptr; + getTrackGuid_ = nullptr; + guidToString_ = nullptr; hostApp_ = nullptr; if (!context) return false; @@ -62,6 +65,15 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) { // persist.cpp uses, so the instrument derives the project directory identically. enumProjects_ = reinterpret_cast( reaper->getReaperApi("EnumProjects")); + // pS-usage: the (prefix-guarded) usage publish write + the track-identity pair the + // usage record stamps. All degrade to null gracefully — an old REAPER just never + // publishes usage (the extension then protects by bank references only). + setProjExtState_ = reinterpret_cast( + reaper->getReaperApi("SetProjExtState")); + getTrackGuid_ = reinterpret_cast( + reaper->getReaperApi("GetTrackGUID")); + guidToString_ = reinterpret_cast( + reaper->getReaperApi("guidToString")); return getProjExtState_ != nullptr; } @@ -97,6 +109,36 @@ std::optional ReaperBridge::readReasamplerExtState(const std::strin return std::nullopt; // pathologically large (>16 MB) — give up rather than loop } +bool ReaperBridge::writeUsageExtState(const std::string& usageKey, + const std::string& value) { + if (!setProjExtState_ || !hostApp_) return false; + // 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 + // instrument's write surface (banks/view/tail/assign stay extension-owned). + const std::string prefix = kProjExtUsageKeyPrefix; + if (usageKey.compare(0, prefix.size(), prefix) != 0) return false; + + auto* reaper = static_cast(hostApp_); + void* proj = reaper->getReaperParent(3); // null = current project (same as reads) + setProjExtState_(proj, kProjExtNamespace(), usageKey.c_str(), value.c_str()); + // Deliberately NO MarkProjectDirty: a usage change always accompanies a component- + // state change that already dirties the project; an idempotent load-time republish + // must not flag an untouched project as modified. + return true; +} + +std::string ReaperBridge::currentTrackGuid() { + if (!hostApp_ || !getTrackGuid_ || !guidToString_) return {}; + auto* reaper = static_cast(hostApp_); + void* track = reaper->getReaperParent(1); // the hosting MediaTrack* + if (!track) return {}; // no track context (unusual host state) + void* guid = getTrackGuid_(track); + if (!guid) return {}; + char buf[64] = {0}; // guidToString's documented destNeed64 contract + guidToString_(guid, buf); + return std::string(buf); +} + std::string ReaperBridge::activeProjectDir() { if (!enumProjects_) return {}; // idx=-1 is the current project tab; the out-buffer receives the full .rpp path, diff --git a/src/vst/reaper_bridge.h b/src/vst/reaper_bridge.h index 8c5dd80..ddd1af2 100644 --- a/src/vst/reaper_bridge.h +++ b/src/vst/reaper_bridge.h @@ -57,6 +57,23 @@ public: // (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe. std::string activeProjectDir(); + // 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 + // 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 + // 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 + // 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); + + // The canonical "{XXXXXXXX-...}" GUID string of the track hosting this FX instance + // (getReaperParent(1) -> GetTrackGUID -> guidToString — the same rendering as the + // extension's track_guid::guidString, so usage records and the extension's live-FX + // enumeration compare byte-equal). Empty when unconnected or no track context (the + // usage reader then falls back to any-instance liveness — fail-safe). Not RT-safe. + std::string currentTrackGuid(); + private: // Resolved REAPER API function pointers (by name via getReaperApi). Signatures // verified against reaper_plugin_functions.h. @@ -69,11 +86,23 @@ private: // ~1264). The instrument uses idx=-1 (current tab) so it follows the active project, // and reads the .rpp path from the out-buffer exactly as persist.cpp does. using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz); + // SetProjExtState(proj, extname, key, value) -> int (SDK line ~6290). Used ONLY by + // writeUsageExtState (prefix-guarded) — see the read-only-bank note there. + using SetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key, + const char* value); + // GetTrackGUID(MediaTrack*) -> GUID* (SDK ~3562) + guidToString(const GUID*, char* + // destNeed64) (SDK ~3848). Both held as opaque-pointer signatures so the header + // stays SDK-type-free; the GUID* is passed straight through, never dereferenced here. + using GetTrackGuidFn = void* (*)(void* tr); + using GuidToStringFn = void (*)(const void* g, char* destNeed64); void* hostApp_ = nullptr; // IReaperHostApplication* (opaque here; used in .cpp) GetProjExtStateFn getProjExtState_ = nullptr; EnumProjExtStateFn enumProjExtState_ = nullptr; EnumProjectsFn enumProjects_ = nullptr; + SetProjExtStateFn setProjExtState_ = nullptr; + GetTrackGuidFn getTrackGuid_ = nullptr; + GuidToStringFn guidToString_ = nullptr; }; } // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 0a846a8..b99fecb 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -4,8 +4,10 @@ #include #include +#include #include #include +#include #include #include @@ -23,6 +25,7 @@ #include "reasampler_editor.h" #include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) #include "sample_map.h" // refs resolve, buildZonedKeymap, state (de)ser (pS self-contained) +#include "sample_usage.h" // pS-usage publish plan + wire (prune-protection seam) #include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) using namespace Steinberg; @@ -47,6 +50,21 @@ constexpr std::size_t kPreserveVoiceCap = 8; constexpr float kGainRampRate = 1.0f / 960.0f; // 960 samples @ 48 kHz ≈ 20 ms constexpr float kGainRampSnap = kGainRampRate * 0.5f; +// pS-usage: mint a fresh per-instance publish identity — 32 lowercase hex chars from the +// OS entropy source. Uniqueness (not cryptographic 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::random_device rd; + std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); + std::uniform_int_distribution dist; + char buf[33] = {0}; + std::snprintf(buf, sizeof(buf), "%016llx%016llx", + static_cast(dist(gen)), + static_cast(dist(gen))); + return std::string(buf); +} + // Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on // any failure — the caller treats an unreadable WAV as "nothing to play". std::vector readFileBytes(const std::string& path) { @@ -258,6 +276,15 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { std::lock_guard lock(refsMutex_); sampleRefs_ = cs.sampleRefs; } + // 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 + // LIFETIME for the copy-collision analysis (planUsagePublish must compare the key's + // current value against what THIS incarnation wrote, not a previous one's writes). + { + std::lock_guard lock(usageMutex_); + instanceGuid_ = cs.instanceGuid; + lastPublishedUsageWire_.clear(); + } // 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). legacyLiftConcluded_.store(false, std::memory_order_relaxed); @@ -302,6 +329,12 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { state_out.sampleRefs = sampleRefs(); retainRefs(state_out.sampleRefs, referencedSampleIds(state_out.selectionId, state_out.map)); + // pS-usage: persist the publish identity (v11) so the instance's usage key is + // stable across sessions (records do not proliferate per reopen). + { + std::lock_guard lock(usageMutex_); + state_out.instanceGuid = instanceGuid_; + } const std::vector bytes = serializeComponentState(state_out); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), @@ -601,9 +634,55 @@ std::string ReaSamplerProcessor::reloadInstrument() { // `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted // pointer is re-owned by the graveyard. publishBuiltLocked(std::move(built)); + + // 5. pS-usage: publish this instance's held captures so the extension's prune can + // never reclaim them (see publishUsage). AFTER the instrument swap, still off the + // audio thread and under reloadMutex_. Publishes regardless of decode success: + // the holds are the refs the instance RETAINS (its play-set), not what decoded — + // a transiently unreadable WAV must stay protected. + publishUsage(refs, ids); return resolvedId; } +void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, + const std::vector& ids) { + if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do + + UsageRecord mine; + mine.trackGuid = bridge_.currentTrackGuid(); + for (const std::string& id : ids) { + if (const SelectedSample* ref = findRef(refs, id)) { + if (!ref->relativePath.empty()) { + mine.holds.push_back(UsageHold{id, ref->relativePath}); + } + } + } + + std::lock_guard lock(usageMutex_); + // A never-published instance with nothing held writes nothing — no key litter for + // fresh/empty instances. Once an identity exists, empties DO publish (they release + // holds the prune would otherwise keep protecting). + if (instanceGuid_.empty() && mine.holds.empty()) return; + if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid(); + + const std::optional existing = + bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_)); + const UsagePublishPlan plan = + planUsagePublish(existing, lastPublishedUsageWire_, mine); + if (plan.remint) { + // 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 + // identity's record dies by the extension's liveness rule when its track no + // longer hosts an instance. getState persists the new guid on the next save. + instanceGuid_ = mintUsageInstanceGuid(); + } else if (plan.skipWrite) { + return; // byte-identical to what this lifetime already wrote — idle tick + } + if (bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire)) { + lastPublishedUsageWire_ = plan.wire; + } +} + void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { // REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by // reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance. diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 62b3cc4..98aaf99 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -302,6 +302,19 @@ private: // safety-critical swap dance (see the handoff proof below). void publishBuiltLocked(std::unique_ptr built); + // pS-usage: publish this instance's held captures to its per-instance ext-state key + // ("usage_") 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 + // reloadInstrument (the ONE choke point every play-set change funnels through: + // selection change, zone edits, assignment consume, bank refresh, setState load), so + // publishing is EAGER and needs no timer — a closed-editor instance's record is + // already in ext-state from its last change/load. OFF THE AUDIO THREAD only (bridge + // calls). Mints instanceGuid_ on first need; RE-mints when planUsagePublish detects + // this state was cloned onto another track (FX copy / track duplication). Idempotent + // on an unchanged play-set (skipWrite). `refs`/`ids` are reloadInstrument's own + // snapshot — the refs table and the id set the instance currently plays. + void publishUsage(const SampleRefs& refs, const std::vector& ids); + ReaperBridge bridge_; // --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) -- @@ -368,6 +381,17 @@ private: std::mutex refsMutex_; SampleRefs sampleRefs_; + // pS-usage publish identity + lifetime memory (see publishUsage). instanceGuid_ is + // the persisted per-instance identity (ComponentState v11; empty until first + // publish); lastPublishedUsageWire_ is what THIS lifetime last wrote — the + // planUsagePublish discriminator between "my own key" (clean replace) and "a + // copy-source's key" (union / re-mint), cleared on setState (a new blob is a new + // lifetime for the collision analysis). Guarded by usageMutex_ (publish runs under + // reloadMutex_ but getState/setState do not). + std::mutex usageMutex_; + std::string instanceGuid_; + std::string lastPublishedUsageWire_; + // 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 // on the audio thread — process renders against the host's negotiated output channel count. diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index f498c04..959582d 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -759,6 +759,11 @@ std::vector serializeComponentState(const ComponentState& state) { putU32le(out, static_cast(e.displayName.size())); out.insert(out.end(), e.displayName.begin(), e.displayName.end()); } + // v11 envelope addition (pS-usage instance identity): the minted per-instance guid, + // length-prefixed, following the refs table so a v10 blob is a strict prefix up to + // here (see the v10 lift). Empty = never published — legal, round-trips as empty. + putU32le(out, static_cast(state.instanceGuid.size())); + out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end()); // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed — // unlike the v1 selection blob where the id ran to end-of-stream). putU32le(out, static_cast(state.selectionId.size())); @@ -835,6 +840,7 @@ ComponentState deserializeComponentState(const std::vector& bytes, return out; // previewVelocity stays at the mid default (pre-S-VIEW-4) } if (version != kComponentStateVersion && + version != kSelectionZonesRefsV10Version && version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version && version != kSelectionZonesModeMarkerVelVoiceGainV8Version && version != kSelectionZonesModeMarkerVelVoiceV7Version && @@ -924,6 +930,13 @@ ComponentState deserializeComponentState(const std::vector& bytes, } if (!r.ok) return out; } + // v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the + // EMPTY default holds and the processor mints a fresh identity on first publish. + if (version >= kSelectionZonesRefsIdentityV11Version) { + const std::uint32_t guidLen = r.u32(); + out.instanceGuid = r.str(guidLen); + if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty + } const std::uint32_t idLen = r.u32(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 27344b6..ea74e0a 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -540,10 +540,13 @@ PerformanceMap deserializePerformance(const std::vector& bytes, // channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the // mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the // instance-owned path + intrinsics + display name per referenced sample; wire shape at -// kSelectionZonesRefsV10Version below), then 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 +// "usage_" 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 // serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). -// The refs table is the ONLY envelope-v10 addition over v9 — the envelope grew a field, +// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the +// only v10 addition over v9 — the envelope grows a field, // the zones payload is untouched (a PARALLEL track owns zone-record extension under its own // versioning; the two version numbers are independent axes — do NOT bump the zones-payload // version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range @@ -554,8 +557,10 @@ PerformanceMap deserializePerformance(const std::vector& bytes, // master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the // un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD // deliberately chosen a mode re-toggles once and the choice persists explicit from then on — -// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path): -// * v10 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, selectionId, zones} direct. +// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path — +// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish): +// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct. +// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage. // * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift). // * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode). // * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain). @@ -610,9 +615,21 @@ struct ComponentState { // pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve // path once (then re-saves self-contained). SampleRefs sampleRefs; + // pS-usage (v11): the minted per-instance identity the usage publisher keys its + // "usage_" ext-state record under (see sample_usage.h — the prune-protection + // 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 + // mints one on first publish, and RE-mints when the publish plan detects this state + // was cloned onto another track (FX copy / track duplication — planUsagePublish). + std::string instanceGuid; }; -inline constexpr std::uint32_t kComponentStateVersion = 10; +inline constexpr std::uint32_t kComponentStateVersion = 11; + +// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed +// after the refs table). Mirrors the v10/v9/… series so the version branches in +// deserializeComponentState stay self-describing. +inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11; // The pS self-contained combined-state version (v9 + the instance-owned sample-refs table). // Wire shape of the refs block (inserted after the v9 explicit flag, before the selection diff --git a/tests/test_prune_reconcile.cpp b/tests/test_prune_reconcile.cpp index fe48216..8a283d1 100644 --- a/tests/test_prune_reconcile.cpp +++ b/tests/test_prune_reconcile.cpp @@ -383,6 +383,35 @@ static void testDeletePlanDeduplicatesConfirmed() { CHECK((plan == std::vector{"b/a.wav", "b/b.wav"})); } +// --- mergeReferenced: the pS-usage referenced-union (bank refs ∪ instance holds) ----- + +// The load-bearing property: a file held ONLY by a sampler instance (not referenced by +// any bank — e.g. its bank entry was deleted while the instance kept its v10 ref) joins +// `referenced` through the union, so pruneOrphans can never emit it. Removing the hold +// (the instance went away — liveness filtered its record out upstream) reclaims it again. +static void testMergeReferencedProtectsInstanceHeldFile() { + const std::vector present{"b/held.wav", "b/orphan.wav"}; + const std::vector owned{"b/held.wav", "b/orphan.wav"}; + const std::vector bankRefs{}; // no bank references either file + + const std::vector withHold = + pruneOrphans(present, mergeReferenced(bankRefs, {"b/held.wav"}), owned); + CHECK((withHold == std::vector{"b/orphan.wav"})); + + const std::vector withoutHold = + pruneOrphans(present, mergeReferenced(bankRefs, {}), owned); + CHECK(withoutHold.size() == 2); // no live hold -> both reclaim (no permanent block) +} + +// Order-preserving exact-string de-dup: primary first, then the extras not already seen. +static void testMergeReferencedOrderDedupAndExactMatch() { + const std::vector merged = mergeReferenced( + {"b/a.wav", "b/b.wav", "b/a.wav"}, {"b/b.wav", "b/c.wav", "B/A.WAV"}); + // Case differs -> distinct entry (exact-string convention, never case-folded here). + CHECK((merged == std::vector{"b/a.wav", "b/b.wav", "b/c.wav", "B/A.WAV"})); + CHECK(mergeReferenced({}, {}).empty()); +} + int main() { testNullTestAllReferenced(); testFormulaMixedPopulations(); @@ -411,6 +440,8 @@ int main() { testDeletePlanNeverSweepsUnconfirmed(); testDeletePlanEmptyInputs(); testDeletePlanDeduplicatesConfirmed(); + testMergeReferencedProtectsInstanceHeldFile(); + testMergeReferencedOrderDedupAndExactMatch(); if (g_fail == 0) std::printf("prune_reconcile_tests: ALL PASS\n"); else std::printf("prune_reconcile_tests: %d FAILURE(S)\n", g_fail); diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index afba62c..427cbcb 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -2272,6 +2272,79 @@ static void testComponentStateV9LiftsToEmptyRefs() { CHECK(back.map.zones.empty()); } +static void testInstanceGuidRoundTripV11() { + // v11 (pS-usage): the minted publish identity round-trips with the envelope + // neighbours (refs table before it, selection/zones after it) intact — the guid + // read consumed exactly its own bytes. An empty guid (never published) is legal + // and round-trips empty. + ComponentState s; + s.selectionId = "kick"; + s.instanceGuid = "0123456789abcdef0123456789abcdef"; + s.sampleRefs.push_back(refEntry("kick", "reasampler_bank/kick.wav", 36)); + s.map.zones.push_back(zone("kick", 0, 127)); + const ComponentState back = + deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(back.instanceGuid == "0123456789abcdef0123456789abcdef"); + CHECK(back.sampleRefs.size() == 1); + CHECK(back.selectionId == "kick"); + CHECK(back.map.zones.size() == 1); + + ComponentState fresh; + fresh.selectionId = "s"; + const ComponentState freshBack = + deserializeComponentState(serializeComponentState(fresh), 44100.0); + CHECK(freshBack.instanceGuid.empty()); + CHECK(freshBack.selectionId == "s"); +} + +static void testComponentStateV10LiftsToEmptyGuid() { + // OLD-BLOB FALLBACK: a genuine v10 blob (refs table but no instance guid) restores + // with an EMPTY guid — the processor mints one on first publish — and every other + // field intact. Hand-built (serializeComponentState now emits v11, so it cannot + // make a v10 blob). + std::vector v10; + v10.push_back(10); v10.push_back(0); v10.push_back(0); v10.push_back(0); // version 10 + v10.push_back(0); // mode = mono + for (int i = 0; i < 8; ++i) v10.push_back(0); // marker = 0 + v10.push_back(88); // preview velocity + v10.push_back(7); // voice count + v10.push_back(0); // voice mode = poly + v10.push_back(0); // trigger = retrigger + for (int i = 0; i < 8; ++i) v10.push_back(0); // gain double bytes... + v10[17 + 6] = 0xF0; v10[17 + 7] = 0x3F; // ...= 1.0 (LE IEEE-754) + v10.push_back(1); // explicit flag = true + // The v10 refs table: ONE entry {id "a", path "b/a.wav", root 36, no loop, ch 0, no name}. + v10.push_back(1); v10.push_back(0); v10.push_back(0); v10.push_back(0); // ref count 1 + v10.push_back(1); v10.push_back(0); v10.push_back(0); v10.push_back(0); // id len 1 + v10.push_back('a'); + const std::string relPath = "b/a.wav"; + v10.push_back(static_cast(relPath.size())); + v10.push_back(0); v10.push_back(0); v10.push_back(0); + v10.insert(v10.end(), relPath.begin(), relPath.end()); + v10.push_back(36); v10.push_back(0); v10.push_back(0); v10.push_back(0); // root 36 + v10.push_back(0); // hasLoop = false + for (int i = 0; i < 16; ++i) v10.push_back(0); // loop start+end + v10.push_back(0); v10.push_back(0); v10.push_back(0); v10.push_back(0); // channels 0 + v10.push_back(0); v10.push_back(0); v10.push_back(0); v10.push_back(0); // name len 0 + // NO guid field (the v11 addition) — the selection id follows directly. + const std::string id = "saved"; + v10.push_back(static_cast(id.size())); + v10.push_back(0); v10.push_back(0); v10.push_back(0); + v10.insert(v10.end(), id.begin(), id.end()); + v10.push_back(0); v10.push_back(0); v10.push_back(0); v10.push_back(0); // zone count 0 + const ComponentState back = deserializeComponentState(v10, 44100.0); + CHECK(back.instanceGuid.empty()); // pre-v11 blob -> empty guid (minted on publish) + CHECK(back.sampleRefs.size() == 1); + CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "a"); + CHECK(back.sampleRefs.size() == 1 && + back.sampleRefs[0].ref.relativePath == "b/a.wav"); + CHECK(back.selectionId == "saved"); + CHECK(back.channelModeExplicit); + CHECK(back.previewVelocity == 88); + CHECK(back.voiceCount == 7); + CHECK(back.map.zones.empty()); +} + static void testReferencedSampleIdsDedup() { // Selection first, then map order, duplicates collapsed; an empty selection contributes // nothing (no phantom "" id in the refs table). @@ -2524,6 +2597,8 @@ int main() { testResolveFromRefsMissingRefDrops(); testResolveFromRefsMatchesBankResolve(); testComponentStateV9LiftsToEmptyRefs(); + testInstanceGuidRoundTripV11(); + testComponentStateV10LiftsToEmptyGuid(); testReferencedSampleIdsDedup(); testRefreshRefsFromBankUpsertAndOwnership(); testRetainRefsFiltersToPlayedSet(); diff --git a/tests/test_sample_usage.cpp b/tests/test_sample_usage.cpp new file mode 100644 index 0000000..a1f5b38 --- /dev/null +++ b/tests/test_sample_usage.cpp @@ -0,0 +1,264 @@ +// Standalone tests for reasampler::sample_usage — no REAPER, no framework. +// The pS-usage seam: instances publish held captures; the extension folds live +// instances' holds into the prune's `referenced` set. Tested hard here because this is +// the prune-protection guarantee: a capture held by a live instance must be IMPOSSIBLE +// to prune (the composed proof at the bottom links prune_reconcile and shows +// pruneOrphans can never emit a held path), while a stale record from a deleted +// instance must NOT permanently block reclaim (the liveness fold). +// +// Covers: wire round-trip (empty / adversarial bytes), malformed -> nullopt, the +// publish plan's four branches (fresh key / clean replace / same-track union / +// cross-track re-mint) + skipWrite idempotence, the liveness fold (live, dead-track, +// empty-guid fallback, de-dup), and the composed pruneOrphans exclusion proof. + +#include "../src/sample_usage.h" + +#include +#include +#include +#include + +#include "../src/prune_reconcile.h" // mergeReferenced + pruneOrphans (composed proof) + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +namespace { + +UsageRecord makeRecord(const std::string& trackGuid, + std::vector holds) { + UsageRecord r; + r.trackGuid = trackGuid; + r.holds = std::move(holds); + return r; +} + +} // namespace + +// --- wire round-trip ---------------------------------------------------------- + +static void testRoundTrip() { + const UsageRecord rec = makeRecord( + "{12345678-1234-1234-1234-1234567890AB}", + {UsageHold{"cap-1700-kick", "reasampler_bank/kick.wav"}, + UsageHold{"cap-1701-snare", "reasampler_bank/snare.wav"}}); + const std::string wire = encodeUsageRecord(rec); + auto back = decodeUsageRecord(wire); + CHECK(back.has_value()); + CHECK(*back == rec); + CHECK(encodeUsageRecord(*back) == wire); // deterministic re-encode +} + +static void testRoundTripEmptyHoldsAndEmptyGuid() { + // An empty-holds record is LEGAL (an instance releasing everything it held), and an + // empty trackGuid is legal (no track context at publish -> any-instance fallback). + const UsageRecord rec = makeRecord("", {}); + auto back = decodeUsageRecord(encodeUsageRecord(rec)); + CHECK(back.has_value()); + CHECK(back->trackGuid.empty()); + CHECK(back->holds.empty()); +} + +static void testRoundTripAdversarialBytes() { + // Ids/paths carrying the wire's own metacharacters must survive whole (the whole + // point of length-prefixing): ':' delimiters, digits, the magic tag itself. + const UsageRecord rec = makeRecord( + "12:34:guid-with-colons", + {UsageHold{"rsusage1-lookalike", "path with spaces/and:colons/7:x.wav"}}); + auto back = decodeUsageRecord(encodeUsageRecord(rec)); + CHECK(back.has_value()); + CHECK(*back == rec); +} + +static void testDecodeMalformed() { + CHECK(!decodeUsageRecord("").has_value()); + CHECK(!decodeUsageRecord("garbage").has_value()); + CHECK(!decodeUsageRecord("rsusage1").has_value()); // truncated after magic + CHECK(!decodeUsageRecord("rsusage2" "0:1:0").has_value()); // wrong magic version + // Truncated mid-holds: claims 2 holds, carries 1. + UsageRecord one = makeRecord("{G}", {UsageHold{"a", "p.wav"}}); + std::string wire = encodeUsageRecord(one); + // Rewrite the count field "1:1" -> "1:2" (count is the 2nd field: len 1, value '1'). + const std::string needle = "1:1"; // count field for one hold + const std::size_t pos = wire.find(needle, std::string("rsusage1").size() + 4); + CHECK(pos != std::string::npos); + wire[pos + 2] = '2'; + CHECK(!decodeUsageRecord(wire).has_value()); + // Trailing garbage after a complete record -> reject whole. + CHECK(!decodeUsageRecord(encodeUsageRecord(one) + "x").has_value()); +} + +// --- publish plan -------------------------------------------------------------- + +static void testPlanFreshKey() { + const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "p.wav"}}); + const UsagePublishPlan plan = planUsagePublish(std::nullopt, "", mine); + CHECK(!plan.remint); + CHECK(!plan.skipWrite); + CHECK(plan.wire == encodeUsageRecord(mine)); +} + +static void testPlanCleanReplaceAndSkip() { + // The key holds exactly what this lifetime wrote -> clean replace; released holds drop. + const UsageRecord prev = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}, + UsageHold{"b", "pb.wav"}}); + const std::string prevWire = encodeUsageRecord(prev); + const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}}); + const UsagePublishPlan plan = planUsagePublish(prevWire, prevWire, mine); + CHECK(!plan.remint); + CHECK(!plan.skipWrite); + auto back = decodeUsageRecord(plan.wire); + CHECK(back.has_value()); + CHECK(back->holds.size() == 1); // 'b' genuinely released — NOT unioned back in + + // Unchanged play-set -> byte-identical write -> skip (idle reload tick). + const UsagePublishPlan idle = planUsagePublish(prevWire, prevWire, prev); + CHECK(idle.skipWrite); + CHECK(!idle.remint); +} + +static void testPlanSameTrackUnion() { + // First publish of a lifetime (lastPublished empty) over a same-track existing value: + // my own last-session record OR a same-track copy-sibling — either way UNION, never + // drop (the fail-safe direction; a sibling's holds must survive my write). + const UsageRecord theirs = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}}); + const UsageRecord mine = makeRecord("{T1}", {UsageHold{"b", "pb.wav"}, + UsageHold{"a", "pa.wav"}}); + const UsagePublishPlan plan = + planUsagePublish(encodeUsageRecord(theirs), "", mine); + CHECK(!plan.remint); + auto back = decodeUsageRecord(plan.wire); + CHECK(back.has_value()); + CHECK(back->trackGuid == "{T1}"); + CHECK(back->holds.size() == 2); // a (existing-first) + b, de-duped + CHECK(back->holds[0].sampleId == "a"); + CHECK(back->holds[1].sampleId == "b"); +} + +static void testPlanCrossTrackRemint() { + // The key holds a foreign record from ANOTHER track: this state was cloned there + // (FX copy / track duplication) — take a fresh identity, leave theirs untouched. + const UsageRecord theirs = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}}); + const UsageRecord mine = makeRecord("{T2}", {UsageHold{"a", "pa.wav"}}); + const UsagePublishPlan plan = + planUsagePublish(encodeUsageRecord(theirs), "", mine); + CHECK(plan.remint); + CHECK(plan.wire == encodeUsageRecord(mine)); // written under the NEW key +} + +static void testPlanUndecodableExisting() { + // An undecodable existing value protects nothing — overwrite with mine. + const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}}); + const UsagePublishPlan plan = planUsagePublish(std::string("corrupt"), "", mine); + CHECK(!plan.remint); + CHECK(plan.wire == encodeUsageRecord(mine)); +} + +// --- liveness fold --------------------------------------------------------------- + +static void testHeldPathsLiveness() { + const std::vector records = { + makeRecord("{LIVE}", {UsageHold{"a", "pa.wav"}}), + makeRecord("{DEAD}", {UsageHold{"b", "pb.wav"}}), // deleted track/instance + makeRecord("", {UsageHold{"c", "pc.wav"}}), // no track context + }; + const std::unordered_set live = {"{LIVE}"}; + + // Live-track record counts; dead-track record is EXCLUDED (no stale false-protect); + // empty-guid record counts while ANY instance lives (fail-safe fallback). + const std::vector withAny = usageHeldPaths(records, live, true); + CHECK(withAny.size() == 2); + CHECK(withAny[0] == "pa.wav"); + CHECK(withAny[1] == "pc.wav"); + + // No instance anywhere -> empty-guid fallback closes too; only live-track survives. + const std::vector noAny = usageHeldPaths(records, live, false); + CHECK(noAny.size() == 1); + CHECK(noAny[0] == "pa.wav"); + + // Zero live instances at all -> nothing protected (a project whose instances were + // all deleted cannot be permanently blocked by leftover records). + const std::vector none = + usageHeldPaths(records, std::unordered_set{}, false); + CHECK(none.empty()); +} + +static void testHeldPathsDedupAndEmptyPathSkip() { + const std::vector records = { + makeRecord("{T}", {UsageHold{"a", "shared.wav"}, UsageHold{"x", ""}}), + makeRecord("{T}", {UsageHold{"b", "shared.wav"}, UsageHold{"c", "own.wav"}}), + }; + const std::unordered_set live = {"{T}"}; + const std::vector paths = usageHeldPaths(records, live, true); + CHECK(paths.size() == 2); // shared.wav de-duped across records; empty path skipped + CHECK(paths[0] == "shared.wav"); + CHECK(paths[1] == "own.wav"); +} + +// --- the composed prune-protection proof ----------------------------------------- +// The definition-of-done property at the pure layer: a capture held by a live instance +// lands in the referenced union, and pruneOrphans can NEVER emit it — even when the +// bank no longer references it (deleted from the bank while the instance kept its ref) +// and it is owned + present (the exact preconditions under which it WOULD be reclaimed). + +static void testInstanceHoldMakesPathUnprunable() { + const std::vector present = {"held.wav", "orphan.wav"}; + const std::vector owned = {"held.wav", "orphan.wav"}; + const std::vector bankRefs = {}; // bank does NOT reference either + + // Without instance usage both are orphans (the pre-pS-usage behavior). + CHECK(pruneOrphans(present, bankRefs, owned).size() == 2); + + // A live instance holds held.wav -> the union protects it; orphan.wav still reclaims. + const std::vector records = { + makeRecord("{T}", {UsageHold{"id-held", "held.wav"}})}; + const std::unordered_set live = {"{T}"}; + const std::vector referenced = + mergeReferenced(bankRefs, usageHeldPaths(records, live, true)); + const std::vector orphans = pruneOrphans(present, referenced, owned); + CHECK(orphans.size() == 1); + CHECK(orphans[0] == "orphan.wav"); + + // The instance (and its track) deleted -> the record no longer counts -> held.wav + // is reclaimable again (no permanent stale-key block). + const std::vector refsAfterDelete = mergeReferenced( + bankRefs, usageHeldPaths(records, std::unordered_set{}, false)); + CHECK(pruneOrphans(present, refsAfterDelete, owned).size() == 2); +} + +static void testMergeReferencedOrderAndDedup() { + const std::vector a = {"p1.wav", "p2.wav"}; + const std::vector b = {"p2.wav", "p3.wav", "p1.wav"}; + const std::vector merged = mergeReferenced(a, b); + CHECK(merged.size() == 3); + CHECK(merged[0] == "p1.wav"); + CHECK(merged[1] == "p2.wav"); + CHECK(merged[2] == "p3.wav"); + CHECK(mergeReferenced({}, {}).empty()); +} + +int main() { + testRoundTrip(); + testRoundTripEmptyHoldsAndEmptyGuid(); + testRoundTripAdversarialBytes(); + testDecodeMalformed(); + testPlanFreshKey(); + testPlanCleanReplaceAndSkip(); + testPlanSameTrackUnion(); + testPlanCrossTrackRemint(); + testPlanUndecodableExisting(); + testHeldPathsLiveness(); + testHeldPathsDedupAndEmptyPathSkip(); + testInstanceHoldMakesPathUnprunable(); + testMergeReferencedOrderAndDedup(); + + if (g_fail == 0) { + std::printf("sample_usage_tests: all tests passed\n"); + return 0; + } + std::printf("sample_usage_tests: %d FAILURES\n", g_fail); + return 1; +} From a4aeb9dcc8e28cca9b7d87ce9e1da237a4599ee9 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 13:32:14 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(pS-usage):=20fail-safe=20prune=20protec?= =?UTF-8?q?tion=20=E2=80=94=20in-wire=20owner=20nonce=20+=20sticky=20union?= =?UTF-8?q?=20poison,=20protect-all=20on=20zero=20identified,=20abort=20on?= =?UTF-8?q?=20unreadable=20record,=20rsusage=5F=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 6 +- src/actions.cpp | 12 + src/ext_keys.h | 15 +- src/persist.cpp | 36 ++- src/persist.h | 4 +- src/prune_reconcile.h | 7 + src/sample_usage.cpp | 162 +++++++++++--- src/sample_usage.h | 166 +++++++++++--- src/usage_scan.cpp | 198 ++++++++-------- src/usage_scan.h | 48 ++-- src/vst/reaper_bridge.cpp | 14 +- src/vst/reaper_bridge.h | 10 +- src/vst/reasampler_processor.cpp | 33 +-- src/vst/reasampler_processor.h | 19 +- src/vst/sample_map.h | 4 +- tests/test_sample_usage.cpp | 372 +++++++++++++++++++++++++------ 16 files changed, 809 insertions(+), 297 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a0e8fa2..caf226b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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: -# the per-instance usage record the INSTRUMENT writes ("usage_" ext-state, +# the per-instance usage record the INSTRUMENT writes ("rsusage_" ext-state, # 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), -# 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. # --------------------------------------------------------------------------- add_library(sample_usage STATIC src/sample_usage.cpp) diff --git a/src/actions.cpp b/src/actions.cpp index f6cfa70..84e8ef3 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -821,6 +821,18 @@ void doBankRemoveSelected() { void doBankPruneFolder() { 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) { ShowConsoleMsg("ReaSampler prune: no orphaned files to reclaim.\n"); return; diff --git a/src/ext_keys.h b/src/ext_keys.h index 9b6b291..674e1f9 100644 --- a/src/ext_keys.h +++ b/src/ext_keys.h @@ -70,17 +70,20 @@ inline constexpr const char* kProjExtBankGenKey = "bank_generation"; inline constexpr const char* kProjExtAssignKey = "assign_request"; // The pS-usage PER-INSTANCE USAGE-RECORD key prefix. The INSTRUMENT writes one key per -// instance — "usage_" — carrying the sample_usage wire record of every +// instance — "rsusage_" — carrying the sample_usage wire record of every // 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 // 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/ // 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- -// 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 = "usage_"; +// prefix). WIRE-SHARED in the write->read direction the other keys reverse. The "rs" +// qualifier is deliberate: a future key that happens to start with "usage_" must never +// be swept into the FX-liveness fold (whose abort-on-unreadable rule would then halt +// every prune), so the prefix is namespaced like the wire magics (rsusage1/rsassign1). +// 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 // point, shared by the instrument's writer and the extension's enumerator). diff --git a/src/persist.cpp b/src/persist.cpp index c7f77e1..67af037 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -306,10 +306,17 @@ constexpr std::size_t kPruneListDisplayCap = 64; // * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration // 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). +// * 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 { std::string bankDirAbs; std::vector orphans; std::unordered_map sizeByRel; + bool abortedUnreadableUsage = false; }; // 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 // the manifest set. pS-usage: the referenced set additionally unions every LIVE // ReaSampler 9000 instance's held captures (usage_scan reads the per-instance - // usage_* records + the live FX enumeration; sample_usage decides liveness) — a - // capture 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, + // rsusage_* records + the live FX enumeration; sample_usage decides liveness, + // including the protect-all net when zero instances were identified) — a capture + // 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, // and stats. 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( - present, mergeReferenced(book.referencedPaths(), liveInstanceHeldPaths(proj)), + present, mergeReferenced(book.referencedPaths(), usage.heldPaths), owned.paths()); return scan; } @@ -380,7 +397,13 @@ PruneReport ReaSamplerSession::pruneDryRun() const { const PruneScan scan = scanPruneOrphans(book_, owned_); // buildPruneReport tallies count / byte-sum / display-truncation — no report logic // 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 ReaSamplerSession::pruneOrphanSet() const { @@ -466,6 +489,9 @@ PruneDeletionResult ReaSamplerSession::pruneReclaim( // 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 // 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_); if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing diff --git a/src/persist.h b/src/persist.h index 5bae09a..52b5cfc 100644 --- a/src/persist.h +++ b/src/persist.h @@ -186,9 +186,11 @@ public: // own convention (bankRelativeForName — byte-identical to the capture path's spelling), // and feeds the R1 pure core with (present, referenced, owned().paths()) where // `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 // 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 // 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 diff --git a/src/prune_reconcile.h b/src/prune_reconcile.h index b4d80ca..e90ea7a 100644 --- a/src/prune_reconcile.h +++ b/src/prune_reconcile.h @@ -64,11 +64,18 @@ namespace reasampler { // `truncated` says whether the list was clipped. // * truncated — true iff `orphans` holds fewer than `count` entries (a large set was // 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 { std::size_t count = 0; std::uint64_t totalBytes = 0; std::vector orphans; bool truncated = false; + bool abortedUnreadableUsage = false; }; // The outcome of an actual prune DELETION (Phase R, Wave 3 — R3). The prune shell fills diff --git a/src/sample_usage.cpp b/src/sample_usage.cpp index 669d956..62dc3ac 100644 --- a/src/sample_usage.cpp +++ b/src/sample_usage.cpp @@ -2,6 +2,7 @@ #include "sample_usage.h" +#include #include #include @@ -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 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 decodeUsageRecord(const std::string& wire) { } UsagePublishPlan planUsagePublish(const std::optional& 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 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 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 usageHeldPaths( bool anyInstanceLive) { std::vector out; std::unordered_set 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 usageHeldPaths( return out; } +UsageFoldResult foldUsageRecords( + const std::vector>& decoded, + const std::unordered_set& liveTrackGuids, + bool anyInstanceLive) { + UsageFoldResult result; + std::vector records; + records.reserve(decoded.size()); + for (const std::optional& 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(std::toupper(static_cast(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 diff --git a/src/sample_usage.h b/src/sample_usage.h index 5f1f2c2..7ec8af6 100644 --- a/src/sample_usage.h +++ b/src/sample_usage.h @@ -2,7 +2,7 @@ // 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 // SampleRefs — sample ids + project-relative paths) to a per-instance project ext-state -// key ("usage_", see ext_keys.h); the EXTENSION reads every usage record +// key ("rsusage_", 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 // 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 @@ -21,7 +21,22 @@ // 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, // 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) -------------------- // @@ -45,22 +60,40 @@ // 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 // 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 -// hosts a sibling 9000 keeps its record alive (false-PROTECT only — prune skips a file -// it could have reclaimed; never the delete direction). Bounded, documented, accepted. +// project — the fail-safe fallback. And the identity-failure net: when records exist +// but ZERO instances were identified live anywhere, EVERY record's paths are protected +// (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) ----------------------------- // // 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 -// state byte-for-byte), so two live instances can wake up sharing one key. The publish -// plan resolves every collision in the fail-safe direction: -// * existing value == what THIS instance wrote this lifetime -> clean replace (the -// normal single-owner path; holds the instance released genuinely drop). -// * existing value is foreign but from the SAME track -> UNION of holds (a same-track -// copy; neither sibling's holds may be dropped — over-protects until the next clean -// replace, never under-protects). -// * existing value is foreign from a DIFFERENT track -> RE-MINT (a cross-track copy +// state byte-for-byte), so two live instances can wake up sharing one key. Worse, two +// same-track copies converge on byte-identical wires, so "existing == what I last +// wrote" is NOT a sound ownership test — a sibling's byte-identical write would pass +// it, and a later clean replace would silently drop the sibling's holds (the delete +// direction). TWO in-wire facts close this: +// * ownerNonce — a per-LIFETIME nonce minted fresh in memory each instance lifetime, +// NEVER persisted (a persisted nonce would clone with the state, recreating the +// 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 // 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 -// time ("{...}" canonical form; empty when the host context could not resolve one) 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. +// time ("{...}" canonical form; empty when the host context could not resolve one), the +// writing incarnation's per-LIFETIME ownerNonce (the exact "did I write this?" ownership +// 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 { std::string trackGuid; + std::string ownerNonce; + bool unioned = false; std::vector holds; 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 // in a GUID or path round-trip whole. Deterministic. // -// FORMAT: "rsusage1" ':' ':' -// then per hold: ':' ':' +// FORMAT: "rsusage1" ':' ':' ':' +// ':' then per hold: ':' ':' std::string encodeUsageRecord(const UsageRecord& rec); // Parse a wire string produced by encodeUsageRecord. std::nullopt on malformed / -// truncated / trailing-garbage input (never UB, never a partial value). The extension -// treats an undecodable record as absent — it can protect nothing it cannot read. +// truncated / trailing-garbage input (never UB, never a partial value). The prune scan +// treats an undecodable record as UNREADABLE and ABORTS (foldUsageRecords) — it must +// never proceed with protection it cannot read. std::optional decodeUsageRecord(const std::string& wire); // 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 // on another track: the caller must mint a fresh instance GUID and // write under the NEW key, leaving the existing record untouched. -// * skipWrite — true when the write would be byte-identical to what this instance -// already wrote this lifetime (idle reload tick) — skip the ext-state -// churn entirely. +// * skipWrite — true when the write would change nothing that matters: byte-identical +// to the existing value (idle reload tick), or a union over an +// 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). struct UsagePublishPlan { bool remint = false; @@ -123,29 +166,80 @@ struct UsagePublishPlan { std::string wire; }; -// Decide what to write for `mine` given the key's current value and what this instance -// last wrote THIS LIFETIME (empty string = nothing yet this lifetime — a fresh load; -// the existing value is then this instance's own persisted record from the last -// session, OR a copy-source's record: same-track -> union, other-track -> remint). -// * existing absent or undecodable -> write mine. -// * existing == lastPublishedThisLifetime -> write mine (clean replace). -// * existing.trackGuid == mine.trackGuid -> write union(existing.holds, mine.holds) -// (existing-first order, de-duped). -// * else -> remint = true, write mine (new key). +// Decide what to write for `mine` given the key's current value. `mine.ownerNonce` is +// 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). +// * 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& existing, - const std::string& lastPublishedThisLifetime, const UsageRecord& mine); -// The prune-side fold: every project-relative path held by a LIVE instance, de-duped, -// in (record, hold) input order. A record counts iff +// The prune-side liveness fold: every project-relative path held by a LIVE instance, +// de-duped, in (record, hold) input order. A record counts iff // * 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 // * its trackGuid is empty and `anyInstanceLive` is true (the fail-safe fallback for // 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). std::vector usageHeldPaths( const std::vector& records, const std::unordered_set& liveTrackGuids, 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 heldPaths; +}; +UsageFoldResult foldUsageRecords( + const std::vector>& decoded, + const std::unordered_set& 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 diff --git a/src/usage_scan.cpp b/src/usage_scan.cpp index b73be1a..53a7bcf 100644 --- a/src/usage_scan.cpp +++ b/src/usage_scan.cpp @@ -18,16 +18,17 @@ #include "usage_scan.h" -#include #include +#include +#include #include #include #include -#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 "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 #define REAPERAPI_MINIMAL @@ -52,28 +53,53 @@ namespace reasampler { namespace { -std::string toUpperAscii(const std::string& s) { - std::string out = s; - for (char& c : out) - c = static_cast(std::toupper(static_cast(c))); - return out; +// The three UPPERCASED channel needles identityMatches (pure, sample_usage) checks +// every FX identity string against. One instance drives the whole scan. +struct FxIdentityNeedles { + std::string uidHexUpper; // 32-hex VST3 class UID (may not appear on all builds) + 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; + +// 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: -// 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; -} +constexpr int kMaxContainerDepth = 8; -// 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) { char buf[2048] = {0}; if (!TrackFX_GetNamedConfigParm(tr, fxId, parm, buf, static_cast(sizeof(buf)))) @@ -81,47 +107,26 @@ std::string trackFxParm(MediaTrack* tr, int fxId, const char* parm) { return std::string(buf); } -// True if any FX in the (possibly container-nested) sub-chain rooted at `fxId` is a -// ReaSampler 9000. Containers are walked via the documented container_count / -// container_item.X addressing (v7.06+); `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 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; +std::string takeFxParm(MediaItem_Take* take, int fxId, const char* parm) { + char buf[2048] = {0}; + if (!TakeFX_GetNamedConfigParm(take, fxId, parm, buf, static_cast(sizeof(buf)))) + return {}; + return std::string(buf); } // True if `tr` hosts >= 1 ReaSampler 9000 anywhere: normal chain, record/input chain // (index | 0x1000000), containers recursively. -bool trackHasInstance(MediaTrack* tr, const std::string& uidHexUpper, - const std::string& nameUpper) { - constexpr int kMaxContainerDepth = 8; +bool trackHasInstance(MediaTrack* tr, const FxIdentityNeedles& id) { + const FxParmGetter parm = [tr](int fxId, const char* p) { + return trackFxParm(tr, fxId, p); + }; const int n = TrackFX_GetCount(tr); for (int i = 0; i < n; ++i) { - if (trackFxSubtreeHasInstance(tr, i, uidHexUpper, nameUpper, kMaxContainerDepth)) - return true; + if (fxSubtreeHasInstance(parm, i, id, kMaxContainerDepth)) return true; } const int rec = TrackFX_GetRecCount(tr); for (int i = 0; i < rec; ++i) { - if (trackFxSubtreeHasInstance(tr, 0x1000000 + i, uidHexUpper, nameUpper, - kMaxContainerDepth)) + if (fxSubtreeHasInstance(parm, 0x1000000 + i, id, kMaxContainerDepth)) return true; } 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 // 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 -// nested in a take-FX container is exotic enough that the empty-trackGuid any-instance -// fallback (sample_usage liveness rule) is the documented safety net. -bool itemHasInstance(MediaItem* item, const std::string& uidHexUpper, - const std::string& nameUpper) { +// take). The SAME identity walk as the track path: fx_ident + original_name + container +// recursion (an unrecognized exotic still lands in the pure protect-all net — records +// with zero identified instances protect everything rather than nothing). +bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) { const int takes = CountTakes(item); for (int t = 0; t < takes; ++t) { MediaItem_Take* take = GetMediaItemTake(item, t); if (!take) continue; + const FxParmGetter parm = [take](int fxId, const char* p) { + return takeFxParm(take, fxId, p); + }; const int n = TakeFX_GetCount(take); for (int i = 0; i < n; ++i) { - char buf[2048] = {0}; - if (TakeFX_GetNamedConfigParm(take, i, "fx_ident", buf, - static_cast(sizeof(buf))) && - identityMatches(buf, uidHexUpper, nameUpper)) - return true; + if (fxSubtreeHasInstance(parm, i, id, kMaxContainerDepth)) return true; } } return false; } // 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 -// record protects nothing, which is the DANGEROUS direction here. Empty on absence. -std::string readExtStateValue(ReaProject* proj, const char* key) { +// the hold count, so a fixed buffer risks a truncated decode. Returns nullopt when the +// key cannot be read WHOLE — absent-after-enumeration (rv <= 0) or pathologically large +// (> 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 readExtStateValue(ReaProject* proj, const char* key) { for (int cap = 1 << 12; cap <= (1 << 24); cap <<= 2) { std::vector buf(static_cast(cap), '\0'); 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()); if (static_cast(s.size()) + 1 < cap) return s; // else possibly truncated -> grow and retry } - return {}; + return std::nullopt; // > 16 MB — unreadable whole, never "absent" } } // namespace -std::vector liveInstanceHeldPaths(void* projOpaque) { +UsageScanResult liveInstanceHeldPaths(void* projOpaque) { ReaProject* proj = static_cast(projOpaque); + UsageScanResult result; - // 1. Enumerate the usage_* keys and decode each record. Key names first (values via - // the growing reader — EnumProjExtState's fixed val buffer could truncate a large - // record, and a truncated record decodes to nothing = protects nothing). + // 1. Enumerate the rsusage_* keys and read+decode each record. Key names first + // (values via the growing reader — EnumProjExtState's fixed val buffer could + // truncate a large record). A nullopt element = present-but-unreadable/ + // undecodable -> the pure fold ABORTS the prune. std::vector usageKeys; { char keyBuf[256]; @@ -186,26 +194,30 @@ std::vector liveInstanceHeldPaths(void* projOpaque) { if (key.compare(0, prefix.size(), prefix) == 0) usageKeys.push_back(key); } } - std::vector records; - 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 rec = decodeUsageRecord(value)) { - records.push_back(std::move(*rec)); - } - } - if (records.empty()) return {}; // no instance ever published — skip the FX scan + if (usageKeys.empty()) return result; // no instance ever published — skip the scan - // 2. Enumerate live ReaSampler 9000 hosts. One channel-frozen identity pair drives + std::vector> decoded; + decoded.reserve(usageKeys.size()); + for (const std::string& key : usageKeys) { + const std::optional 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. - const std::string uidHexUpper = toUpperAscii(vstClassIdHex()); - const std::string nameUpper = toUpperAscii(vstPluginName()); + FxIdentityNeedles id; + id.uidHexUpper = toUpperAscii(vstClassIdHex()); + id.outputNameUpper = toUpperAscii(vstOutputName()); + id.nameUpper = toUpperAscii(vstPluginName()); std::unordered_set liveTrackGuids; bool anyLive = false; if (MediaTrack* master = GetMasterTrack(proj)) { - if (trackHasInstance(master, uidHexUpper, nameUpper)) { + if (trackHasInstance(master, id)) { liveTrackGuids.insert(guidString(master)); anyLive = true; } @@ -214,7 +226,7 @@ std::vector liveInstanceHeldPaths(void* projOpaque) { for (int i = 0; i < trackCount; ++i) { MediaTrack* tr = GetTrack(proj, i); if (!tr) continue; - if (trackHasInstance(tr, uidHexUpper, nameUpper)) { + if (trackHasInstance(tr, id)) { liveTrackGuids.insert(guidString(tr)); anyLive = true; } @@ -225,7 +237,7 @@ std::vector liveInstanceHeldPaths(void* projOpaque) { for (int i = 0; i < itemCount; ++i) { MediaItem* item = GetMediaItem(proj, i); if (!item) continue; - if (itemHasInstance(item, uidHexUpper, nameUpper)) { + if (itemHasInstance(item, id)) { if (MediaTrack* tr = GetMediaItemTrack(item)) { liveTrackGuids.insert(guidString(tr)); } @@ -233,8 +245,12 @@ std::vector liveInstanceHeldPaths(void* projOpaque) { } } - // 3. The pure liveness fold decides which records count. - return usageHeldPaths(records, liveTrackGuids, anyLive); + // 3. The pure fold decides: abort on any unreadable record; protect-all when zero + // 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 diff --git a/src/usage_scan.h b/src/usage_scan.h index 4f93267..f5df64c 100644 --- a/src/usage_scan.h +++ b/src/usage_scan.h @@ -1,26 +1,30 @@ #pragma once // 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: -// which project-relative bank paths are held by a LIVE ReaSampler 9000 instance? +// the pure core, the fail-safe folds, and the full design note). At prune-scan time it +// 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): -// 1. Enumerate every "usage_" key in the "reasampler" ext-state namespace -// (EnumProjExtState) and decode each record (sample_usage wire). +// 1. Enumerate every "rsusage_" key in the "reasampler" ext-state namespace +// (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 // (master included), normal + record/input chains, FX containers recursively, and -// take FX — matching by fx_ident containing this channel's VST3 class-UID hex -// (instrument_drop::vstClassIdHex, the same frozen constants the factory -// registers) with the channel display name as a fallback match. OFFLINE FX are -// included: chain enumeration is chunk-level, so a Design-View-parked instance -// still protects its holds (the reason the instrument never clears its own key — -// see sample_usage.h). -// 3. Fold with the pure liveness rule (sample_usage::usageHeldPaths): a record counts -// iff its publishing track still hosts >= 1 instance (or, for a record with no -// track context, iff any instance exists at all). +// take FX (same container recursion) — matching each FX's fx_ident AND +// original_name via the pure sample_usage::identityMatches (class-UID hex, module +// filename base, display name; see the matcher note there). +// 3. Fold with the pure liveness rule (sample_usage::foldUsageRecords / +// usageHeldPaths): a record counts iff its publishing track still hosts >= 1 +// instance; a record with no track context counts while any instance exists; and +// when records exist but ZERO instances were identified anywhere, EVERY record's +// paths are protected (the identity-failure net — a matcher failure must never +// degrade toward delete). // // The result feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans, so // `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. +// abortPrune propagates through PruneScan/PruneReport to the action, which halts. // // REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT // REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). The @@ -32,10 +36,18 @@ namespace reasampler { -// Every project-relative path held by a live ReaSampler 9000 instance in `proj` -// (nullptr = active project), de-duped, in record order. Empty when no usage records -// exist (the common no-instances case — the FX enumeration is skipped entirely). -// READ-ONLY: no ext-state write, no project mutation. -std::vector liveInstanceHeldPaths(void* proj); +// 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 +// 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 heldPaths; +}; + +// Scan `proj` (nullptr = active project). READ-ONLY: no ext-state write, no project +// mutation. +UsageScanResult liveInstanceHeldPaths(void* proj); } // namespace reasampler diff --git a/src/vst/reaper_bridge.cpp b/src/vst/reaper_bridge.cpp index 403337e..0c5dc49 100644 --- a/src/vst/reaper_bridge.cpp +++ b/src/vst/reaper_bridge.cpp @@ -113,18 +113,24 @@ bool ReaperBridge::writeUsageExtState(const std::string& usageKey, const std::string& value) { if (!setProjExtState_ || !hostApp_) return false; // 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 - // instrument's write surface (banks/view/tail/assign stay extension-owned). + // A non-"rsusage_" key is a programming error upstream — refuse rather than widen + // the instrument's write surface (banks/view/tail/assign stay extension-owned). const std::string prefix = kProjExtUsageKeyPrefix; if (usageKey.compare(0, prefix.size(), prefix) != 0) return false; auto* reaper = static_cast(hostApp_); 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- // state change that already dirties the project; an idempotent load-time republish // must not flag an untouched project as modified. - return true; + return rv > 0; } std::string ReaperBridge::currentTrackGuid() { diff --git a/src/vst/reaper_bridge.h b/src/vst/reaper_bridge.h index ddd1af2..78582de 100644 --- a/src/vst/reaper_bridge.h +++ b/src/vst/reaper_bridge.h @@ -58,13 +58,15 @@ public: std::string activeProjectDir(); // 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 // enforced structurally: this module can publish the instance's own usage and // 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 - // 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. + // Returns true iff written (the SetProjExtState return is checked — a dropped + // write must not silently claim protection). NOT RT-safe (calls into REAPER) — + // 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); // The canonical "{XXXXXXXX-...}" GUID string of the track hosting this FX instance diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index b99fecb..027527c 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -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 kGainRampSnap = kGainRampRate * 0.5f; -// pS-usage: mint a fresh per-instance publish identity — 32 lowercase hex chars from the -// OS entropy source. Uniqueness (not cryptographic 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. +// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy +// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the +// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic +// 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::random_device rd; std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); @@ -277,13 +279,13 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { sampleRefs_ = cs.sampleRefs; } // 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 - // LIFETIME for the copy-collision analysis (planUsagePublish must compare the key's - // current value against what THIS incarnation wrote, not a previous one's writes). + // minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME + // for the copy-collision analysis (the fresh nonce means this incarnation can never + // be mistaken for the previous one's writes — or for a copy-sibling's). { std::lock_guard lock(usageMutex_); instanceGuid_ = cs.instanceGuid; - lastPublishedUsageWire_.clear(); + usageNonce_.clear(); } // 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). @@ -664,11 +666,16 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, // holds the prune would otherwise keep protecting). if (instanceGuid_.empty() && mine.holds.empty()) return; 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 existing = bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_)); - const UsagePublishPlan plan = - planUsagePublish(existing, lastPublishedUsageWire_, mine); + const UsagePublishPlan plan = planUsagePublish(existing, mine); if (plan.remint) { // 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 @@ -676,11 +683,9 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, // longer hosts an instance. getState persists the new guid on the next save. instanceGuid_ = mintUsageInstanceGuid(); } else if (plan.skipWrite) { - return; // byte-identical to what this lifetime already wrote — idle tick - } - if (bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire)) { - lastPublishedUsageWire_ = plan.wire; + return; // idle tick, or a union that adds nothing — no ext-state churn } + bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire); } void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 98aaf99..703884c 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -303,7 +303,7 @@ private: void publishBuiltLocked(std::unique_ptr built); // pS-usage: publish this instance's held captures to its per-instance ext-state key - // ("usage_") so the extension's prune counts them as referenced — a + // ("rsusage_") 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 // reloadInstrument (the ONE choke point every play-set change funnels through: // selection change, zone edits, assignment consume, bank refresh, setState load), so @@ -381,16 +381,21 @@ private: std::mutex refsMutex_; 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 - // publish); lastPublishedUsageWire_ is what THIS lifetime last wrote — the - // planUsagePublish discriminator between "my own key" (clean replace) and "a - // copy-source's key" (union / re-mint), cleared on setState (a new blob is a new - // lifetime for the collision analysis). Guarded by usageMutex_ (publish runs under + // publish); usageNonce_ is THIS incarnation's per-LIFETIME owner nonce, carried + // INSIDE the published wire (UsageRecord.ownerNonce) — planUsagePublish's exact + // ownership discriminator between "my own write" (clean replace) and "a foreign + // 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). std::mutex usageMutex_; std::string instanceGuid_; - std::string lastPublishedUsageWire_; + std::string usageNonce_; // 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 diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index ea74e0a..d8703b9 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -542,7 +542,7 @@ PerformanceMap deserializePerformance(const std::vector& bytes, // 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 // length + guid bytes; the minted per-instance identity the usage publisher keys its -// "usage_" ext-state record under, see sample_usage.h), then a 4-byte LE +// "rsusage_" 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 // 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 @@ -616,7 +616,7 @@ struct ComponentState { // path once (then re-saves self-contained). SampleRefs sampleRefs; // pS-usage (v11): the minted per-instance identity the usage publisher keys its - // "usage_" ext-state record under (see sample_usage.h — the prune-protection + // "rsusage_" ext-state record under (see sample_usage.h — the prune-protection // 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 // mints one on first publish, and RE-mints when the publish plan detects this state diff --git a/tests/test_sample_usage.cpp b/tests/test_sample_usage.cpp index a1f5b38..ec4f595 100644 --- a/tests/test_sample_usage.cpp +++ b/tests/test_sample_usage.cpp @@ -1,19 +1,26 @@ // Standalone tests for reasampler::sample_usage — no REAPER, no framework. // The pS-usage seam: instances publish held captures; the extension folds live // instances' holds into the prune's `referenced` set. Tested hard here because this is -// the prune-protection guarantee: a capture held by a live instance must be IMPOSSIBLE -// to prune (the composed proof at the bottom links prune_reconcile and shows -// pruneOrphans can never emit a held path), while a stale record from a deleted -// instance must NOT permanently block reclaim (the liveness fold). +// the prune-protection guarantee — a SAFETY property: every failure, ambiguity, or +// uncertainty must FAIL-SAFE toward PROTECT (over-protection acceptable; +// under-protection = deleting a maybe-used file is a data-loss bug). // -// Covers: wire round-trip (empty / adversarial bytes), malformed -> nullopt, the -// publish plan's four branches (fresh key / clean replace / same-track union / -// cross-track re-mint) + skipWrite idempotence, the liveness fold (live, dead-track, -// empty-guid fallback, de-dup), and the composed pruneOrphans exclusion proof. +// Covers: wire round-trip (nonce + unioned flag, empty / adversarial bytes), +// malformed -> nullopt, the publish plan's branches (fresh / clean replace + skip / +// sibling union with the sticky poison flag / cross-track re-mint / undecodable heal), +// the SAME-TRACK SIBLING repro (the review's 🔴#1 — byte-identical wire convergence +// must never let one sibling clean-replace the other's still-held paths, including one +// write later via the poison flag), the liveness fold (live, dead-track, empty-guid +// fallback, de-dup), the ZERO-IDENTIFIED protect-all net (🔴#2 — an identity-matcher +// failure must protect everything, not nothing), the UNREADABLE-record abort +// (foldUsageRecords.abortPrune — prune halts, deletes nothing), the pure identity +// matcher (UID hex / module filename base / display name, beta over-protect), and the +// composed pruneOrphans exclusion proof. #include "../src/sample_usage.h" #include +#include #include #include #include @@ -28,37 +35,49 @@ static int g_fail = 0; namespace { -UsageRecord makeRecord(const std::string& trackGuid, - std::vector holds) { +UsageRecord makeRecord(const std::string& trackGuid, const std::string& nonce, + std::vector holds, bool unioned = false) { UsageRecord r; r.trackGuid = trackGuid; + r.ownerNonce = nonce; + r.unioned = unioned; r.holds = std::move(holds); return r; } +bool holdsContainPath(const std::vector& holds, const std::string& path) { + for (const UsageHold& h : holds) + if (h.relativePath == path) return true; + return false; +} + } // namespace // --- wire round-trip ---------------------------------------------------------- static void testRoundTrip() { const UsageRecord rec = makeRecord( - "{12345678-1234-1234-1234-1234567890AB}", + "{12345678-1234-1234-1234-1234567890AB}", "aabbccdd00112233", {UsageHold{"cap-1700-kick", "reasampler_bank/kick.wav"}, - UsageHold{"cap-1701-snare", "reasampler_bank/snare.wav"}}); + UsageHold{"cap-1701-snare", "reasampler_bank/snare.wav"}}, + /*unioned=*/true); const std::string wire = encodeUsageRecord(rec); auto back = decodeUsageRecord(wire); CHECK(back.has_value()); CHECK(*back == rec); + CHECK(back->unioned); CHECK(encodeUsageRecord(*back) == wire); // deterministic re-encode } static void testRoundTripEmptyHoldsAndEmptyGuid() { // An empty-holds record is LEGAL (an instance releasing everything it held), and an // empty trackGuid is legal (no track context at publish -> any-instance fallback). - const UsageRecord rec = makeRecord("", {}); + const UsageRecord rec = makeRecord("", "", {}); auto back = decodeUsageRecord(encodeUsageRecord(rec)); CHECK(back.has_value()); CHECK(back->trackGuid.empty()); + CHECK(back->ownerNonce.empty()); + CHECK(!back->unioned); CHECK(back->holds.empty()); } @@ -66,7 +85,7 @@ static void testRoundTripAdversarialBytes() { // Ids/paths carrying the wire's own metacharacters must survive whole (the whole // point of length-prefixing): ':' delimiters, digits, the magic tag itself. const UsageRecord rec = makeRecord( - "12:34:guid-with-colons", + "12:34:guid-with-colons", "1:0", {UsageHold{"rsusage1-lookalike", "path with spaces/and:colons/7:x.wav"}}); auto back = decodeUsageRecord(encodeUsageRecord(rec)); CHECK(back.has_value()); @@ -77,15 +96,19 @@ static void testDecodeMalformed() { CHECK(!decodeUsageRecord("").has_value()); CHECK(!decodeUsageRecord("garbage").has_value()); CHECK(!decodeUsageRecord("rsusage1").has_value()); // truncated after magic - CHECK(!decodeUsageRecord("rsusage2" "0:1:0").has_value()); // wrong magic version - // Truncated mid-holds: claims 2 holds, carries 1. - UsageRecord one = makeRecord("{G}", {UsageHold{"a", "p.wav"}}); + CHECK(!decodeUsageRecord("rsusage2" "0:0:1:01:0").has_value()); // wrong magic + // A non-"0"/"1" unioned field is corruption -> reject whole. Hand-built wire: + // magic + trackGuid "" + nonce "" + unioned "2" + count 0. + CHECK(!decodeUsageRecord("rsusage1" "0:" "0:" "1:2" "1:0").has_value()); + // Truncated mid-holds: claims 2 holds, carries 1. The count field for one hold is + // the "1:1" that FOLLOWS the unioned field "1:0" (nonce chosen digit-free so the + // needle is unambiguous). + UsageRecord one = makeRecord("{G}", "nonce", {UsageHold{"a", "p.wav"}}); std::string wire = encodeUsageRecord(one); - // Rewrite the count field "1:1" -> "1:2" (count is the 2nd field: len 1, value '1'). - const std::string needle = "1:1"; // count field for one hold - const std::size_t pos = wire.find(needle, std::string("rsusage1").size() + 4); + const std::string needle = "1:0" "1:1"; // unioned=0 then count=1 + const std::size_t pos = wire.find(needle); CHECK(pos != std::string::npos); - wire[pos + 2] = '2'; + wire[pos + 5] = '2'; // count "1:1" -> "1:2" CHECK(!decodeUsageRecord(wire).has_value()); // Trailing garbage after a complete record -> reject whole. CHECK(!decodeUsageRecord(encodeUsageRecord(one) + "x").has_value()); @@ -94,102 +117,200 @@ static void testDecodeMalformed() { // --- publish plan -------------------------------------------------------------- static void testPlanFreshKey() { - const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "p.wav"}}); - const UsagePublishPlan plan = planUsagePublish(std::nullopt, "", mine); + const UsageRecord mine = makeRecord("{T1}", "NA", {UsageHold{"a", "p.wav"}}); + const UsagePublishPlan plan = planUsagePublish(std::nullopt, mine); CHECK(!plan.remint); CHECK(!plan.skipWrite); - CHECK(plan.wire == encodeUsageRecord(mine)); + auto back = decodeUsageRecord(plan.wire); + CHECK(back.has_value()); + CHECK(back->ownerNonce == "NA"); + CHECK(!back->unioned); // sole known writer -> un-poisoned } static void testPlanCleanReplaceAndSkip() { - // The key holds exactly what this lifetime wrote -> clean replace; released holds drop. - const UsageRecord prev = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}, - UsageHold{"b", "pb.wav"}}); + // The key holds this incarnation's own un-poisoned write (nonce match, !unioned): + // the sole-writer path. Clean replace; released holds drop. + const UsageRecord prev = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}, + UsageHold{"b", "pb.wav"}}); const std::string prevWire = encodeUsageRecord(prev); - const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}}); - const UsagePublishPlan plan = planUsagePublish(prevWire, prevWire, mine); + const UsageRecord mine = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}}); + const UsagePublishPlan plan = planUsagePublish(prevWire, mine); CHECK(!plan.remint); CHECK(!plan.skipWrite); auto back = decodeUsageRecord(plan.wire); CHECK(back.has_value()); CHECK(back->holds.size() == 1); // 'b' genuinely released — NOT unioned back in + CHECK(!back->unioned); // Unchanged play-set -> byte-identical write -> skip (idle reload tick). - const UsagePublishPlan idle = planUsagePublish(prevWire, prevWire, prev); + const UsagePublishPlan idle = planUsagePublish(prevWire, prev); CHECK(idle.skipWrite); CHECK(!idle.remint); } -static void testPlanSameTrackUnion() { - // First publish of a lifetime (lastPublished empty) over a same-track existing value: - // my own last-session record OR a same-track copy-sibling — either way UNION, never - // drop (the fail-safe direction; a sibling's holds must survive my write). - const UsageRecord theirs = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}}); - const UsageRecord mine = makeRecord("{T1}", {UsageHold{"b", "pb.wav"}, - UsageHold{"a", "pa.wav"}}); +// The review's 🔴#1 repro. Two same-track FX copies share a key and converge on +// byte-identical hold sets; the OLD wire-equality discriminator let sibling A +// clean-replace over B's still-held path. With the in-wire per-lifetime nonce + +// sticky unioned poison, every same-track collision unions and NO write of A's can +// ever drop B's holds — including A's SECOND write after it re-owns the key. +static void testSiblingCollisionNeverDropsHolds() { + // A (lifetime nonce NA) publishes {pa}. + const UsageRecord aFirst = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}}); + const UsagePublishPlan planA1 = planUsagePublish(std::nullopt, aFirst); + CHECK(!planA1.remint && !planA1.skipWrite); + + // B (lifetime nonce NB, same track, SAME hold set — the byte-identical + // convergence) publishes {pa}: foreign nonce, same track -> UNION, and the + // false->true poison flip is WRITTEN (never skipped), marking the key multi-writer. + const UsageRecord bSame = makeRecord("{T1}", "NB", {UsageHold{"a", "pa.wav"}}); + const UsagePublishPlan planB = planUsagePublish(planA1.wire, bSame); + CHECK(!planB.remint); + CHECK(!planB.skipWrite); // the poison flip must land in ext-state + auto bBack = decodeUsageRecord(planB.wire); + CHECK(bBack.has_value()); + CHECK(bBack->unioned); + CHECK(holdsContainPath(bBack->holds, "pa.wav")); + + // A changes its selection to {pc} (releases pa from ITS play-set — but B still + // plays pa). Foreign nonce (NB) -> union: pa is RETAINED. Pre-fix this was the + // clean-replace that dropped B's hold -> prune could delete B's playing file. + const UsageRecord aSecond = makeRecord("{T1}", "NA", {UsageHold{"c", "pc.wav"}}); + const UsagePublishPlan planA2 = planUsagePublish(planB.wire, aSecond); + CHECK(!planA2.remint); + auto a2Back = decodeUsageRecord(planA2.wire); + CHECK(a2Back.has_value()); + CHECK(holdsContainPath(a2Back->holds, "pa.wav")); // B's hold survives A's write + CHECK(holdsContainPath(a2Back->holds, "pc.wav")); + CHECK(a2Back->unioned); + + // A writes AGAIN (selection {pd}) — now the key carries A's OWN nonce (NA). A bare + // nonce discriminator would clean-replace here and drop pa one step late; the + // sticky unioned poison forces union forever. pa STILL survives. + const UsageRecord aThird = makeRecord("{T1}", "NA", {UsageHold{"d", "pd.wav"}}); + const UsagePublishPlan planA3 = planUsagePublish(planA2.wire, aThird); + CHECK(!planA3.remint); + auto a3Back = decodeUsageRecord(planA3.wire); + CHECK(a3Back.has_value()); + CHECK(holdsContainPath(a3Back->holds, "pa.wav")); // the poison-flag guarantee + CHECK(a3Back->unioned); +} + +static void testPlanUnionSkipOnlyWhenAlreadyPoisoned() { + // Union over an ALREADY-unioned record that adds no holds -> skip (the write would + // flip only the nonce — redundant churn; the protection is already in place). + const UsageRecord poisoned = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}}, + /*unioned=*/true); + const UsageRecord mineSubset = makeRecord("{T1}", "NB", {UsageHold{"a", "pa.wav"}}); const UsagePublishPlan plan = - planUsagePublish(encodeUsageRecord(theirs), "", mine); + planUsagePublish(encodeUsageRecord(poisoned), mineSubset); + CHECK(!plan.remint); + CHECK(plan.skipWrite); + // But a union that ADDS a hold must write even when already poisoned. + const UsageRecord mineNew = makeRecord("{T1}", "NB", {UsageHold{"b", "pb.wav"}}); + const UsagePublishPlan plan2 = + planUsagePublish(encodeUsageRecord(poisoned), mineNew); + CHECK(!plan2.skipWrite); + auto back = decodeUsageRecord(plan2.wire); + CHECK(back.has_value()); + CHECK(back->holds.size() == 2); + CHECK(back->unioned); +} + +static void testPlanEmptyNonceNeverClaimsOwnership() { + // A record written with an empty nonce (defensive: publisher failed to mint) can + // never be claimed via empty==empty — the same-track path must UNION, not replace. + const UsageRecord theirs = makeRecord("{T1}", "", {UsageHold{"a", "pa.wav"}}); + const UsageRecord mine = makeRecord("{T1}", "", {UsageHold{"b", "pb.wav"}}); + const UsagePublishPlan plan = planUsagePublish(encodeUsageRecord(theirs), mine); CHECK(!plan.remint); auto back = decodeUsageRecord(plan.wire); CHECK(back.has_value()); - CHECK(back->trackGuid == "{T1}"); - CHECK(back->holds.size() == 2); // a (existing-first) + b, de-duped - CHECK(back->holds[0].sampleId == "a"); - CHECK(back->holds[1].sampleId == "b"); + CHECK(holdsContainPath(back->holds, "pa.wav")); // never dropped + CHECK(back->unioned); } static void testPlanCrossTrackRemint() { // The key holds a foreign record from ANOTHER track: this state was cloned there // (FX copy / track duplication) — take a fresh identity, leave theirs untouched. - const UsageRecord theirs = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}}); - const UsageRecord mine = makeRecord("{T2}", {UsageHold{"a", "pa.wav"}}); - const UsagePublishPlan plan = - planUsagePublish(encodeUsageRecord(theirs), "", mine); + const UsageRecord theirs = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}}); + const UsageRecord mine = makeRecord("{T2}", "NB", {UsageHold{"a", "pa.wav"}}); + const UsagePublishPlan plan = planUsagePublish(encodeUsageRecord(theirs), mine); CHECK(plan.remint); - CHECK(plan.wire == encodeUsageRecord(mine)); // written under the NEW key + auto back = decodeUsageRecord(plan.wire); + CHECK(back.has_value()); + CHECK(!back->unioned); // written under the NEW key — fresh, un-poisoned +} + +static void testPlanOwnRecordAfterTrackMove() { + // My own un-poisoned record, but the instance moved tracks THIS lifetime (nonce + // matches, track differs): still mine — clean replace with the new track guid, NOT + // a remint (the key stays stable; no record proliferation on a track move). + const UsageRecord prev = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}}); + const UsageRecord mine = makeRecord("{T2}", "NA", {UsageHold{"a", "pa.wav"}}); + const UsagePublishPlan plan = planUsagePublish(encodeUsageRecord(prev), mine); + CHECK(!plan.remint); + auto back = decodeUsageRecord(plan.wire); + CHECK(back.has_value()); + CHECK(back->trackGuid == "{T2}"); } static void testPlanUndecodableExisting() { - // An undecodable existing value protects nothing — overwrite with mine. - const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}}); - const UsagePublishPlan plan = planUsagePublish(std::string("corrupt"), "", mine); + // An undecodable existing value under MY key is corruption — overwrite with mine + // (the self-heal; the prune side independently aborts while it is unreadable). + const UsageRecord mine = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}}); + const UsagePublishPlan plan = planUsagePublish(std::string("corrupt"), mine); CHECK(!plan.remint); - CHECK(plan.wire == encodeUsageRecord(mine)); + CHECK(!plan.skipWrite); + auto back = decodeUsageRecord(plan.wire); + CHECK(back.has_value()); + CHECK(back->holds.size() == 1); } // --- liveness fold --------------------------------------------------------------- static void testHeldPathsLiveness() { const std::vector records = { - makeRecord("{LIVE}", {UsageHold{"a", "pa.wav"}}), - makeRecord("{DEAD}", {UsageHold{"b", "pb.wav"}}), // deleted track/instance - makeRecord("", {UsageHold{"c", "pc.wav"}}), // no track context + makeRecord("{LIVE}", "N1", {UsageHold{"a", "pa.wav"}}), + makeRecord("{DEAD}", "N2", {UsageHold{"b", "pb.wav"}}), // deleted track + makeRecord("", "N3", {UsageHold{"c", "pc.wav"}}), // no track context }; const std::unordered_set live = {"{LIVE}"}; - // Live-track record counts; dead-track record is EXCLUDED (no stale false-protect); - // empty-guid record counts while ANY instance lives (fail-safe fallback). + // Live-track record counts; dead-track record is EXCLUDED (stale-record cleanup — + // possible ONLY because at least one instance was positively identified, so the + // matcher demonstrably works in this project); empty-guid record counts while ANY + // instance lives (fail-safe fallback). const std::vector withAny = usageHeldPaths(records, live, true); CHECK(withAny.size() == 2); CHECK(withAny[0] == "pa.wav"); CHECK(withAny[1] == "pc.wav"); +} - // No instance anywhere -> empty-guid fallback closes too; only live-track survives. - const std::vector noAny = usageHeldPaths(records, live, false); - CHECK(noAny.size() == 1); - CHECK(noAny[0] == "pa.wav"); - - // Zero live instances at all -> nothing protected (a project whose instances were - // all deleted cannot be permanently blocked by leftover records). - const std::vector none = +// The review's 🔴#2 repro: records exist but ZERO instances were identified live +// (either every instance was genuinely deleted, or — indistinguishable — the identity +// matcher failed on every FX). The old fold dropped every record -> all held captures +// became prunable. The fail-safe net protects ALL records' paths instead. +static void testZeroIdentifiedProtectsAll() { + const std::vector records = { + makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}), + makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}}), + makeRecord("", "N3", {UsageHold{"c", "pc.wav"}}), + }; + const std::vector all = usageHeldPaths(records, std::unordered_set{}, 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{}, false).empty()); } static void testHeldPathsDedupAndEmptyPathSkip() { const std::vector records = { - makeRecord("{T}", {UsageHold{"a", "shared.wav"}, UsageHold{"x", ""}}), - makeRecord("{T}", {UsageHold{"b", "shared.wav"}, UsageHold{"c", "own.wav"}}), + makeRecord("{T}", "N1", {UsageHold{"a", "shared.wav"}, UsageHold{"x", ""}}), + makeRecord("{T}", "N2", {UsageHold{"b", "shared.wav"}, UsageHold{"c", "own.wav"}}), }; const std::unordered_set live = {"{T}"}; const std::vector paths = usageHeldPaths(records, live, true); @@ -198,6 +319,92 @@ static void testHeldPathsDedupAndEmptyPathSkip() { CHECK(paths[1] == "own.wav"); } +// A take-FX-hosted instance: the shell attributes it to the ITEM'S OWNING TRACK (the +// same guid the VST-side getReaperParent(1) publishes), so at the pure layer its record +// folds exactly like a track-FX one. This is the pure half of the take-FX guarantee; +// the enumeration itself (TakeFX_* walk, fx_ident + original_name) is shell code. +static void testTakeFxAttributedRecordIsProtected() { + const std::vector records = { + makeRecord("{ITEM-TRACK}", "N1", {UsageHold{"a", "take-held.wav"}}), + }; + const std::unordered_set live = {"{ITEM-TRACK}"}; // set via item scan + const std::vector 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> 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{"{T1}"}, true); + CHECK(fold.abortPrune); + + // All readable -> no abort, normal liveness fold. + std::vector> ok; + ok.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})); + const UsageFoldResult okFold = + foldUsageRecords(ok, std::unordered_set{"{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{}, 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> unmatched; + unmatched.push_back(makeRecord("{T9}", "N1", {UsageHold{"a", "pa.wav"}})); + const UsageFoldResult net = + foldUsageRecords(unmatched, std::unordered_set{}, 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", uid, name, output)); + + // Beta over-protect (deliberate): stable needles are substrings of beta renderings + // — a stable extension protects beta instances' holds too (wider set only). + CHECK(identityMatches("...\\reasampler_9000_beta.vst3", uid, name, output)); + CHECK(identityMatches("ReaSampler 9000 beta", uid, name, output)); + + // Non-matches stay non-matches. + CHECK(!identityMatches("", uid, name, output)); + CHECK(!identityMatches("ReaComp", uid, name, output)); + CHECK(!identityMatches("some_other_sampler.vst3", uid, name, output)); +} + // --- the composed prune-protection proof ----------------------------------------- // The definition-of-done property at the pure layer: a capture held by a live instance // lands in the referenced union, and pruneOrphans can NEVER emit it — even when the @@ -214,7 +421,7 @@ static void testInstanceHoldMakesPathUnprunable() { // A live instance holds held.wav -> the union protects it; orphan.wav still reclaims. const std::vector records = { - makeRecord("{T}", {UsageHold{"id-held", "held.wav"}})}; + makeRecord("{T}", "N1", {UsageHold{"id-held", "held.wav"}})}; const std::unordered_set live = {"{T}"}; const std::vector referenced = mergeReferenced(bankRefs, usageHeldPaths(records, live, true)); @@ -222,10 +429,23 @@ static void testInstanceHoldMakesPathUnprunable() { CHECK(orphans.size() == 1); CHECK(orphans[0] == "orphan.wav"); - // The instance (and its track) deleted -> the record no longer counts -> held.wav - // is reclaimable again (no permanent stale-key block). - const std::vector refsAfterDelete = mergeReferenced( + // Zero instances identified anywhere -> the protect-all net keeps held.wav + // un-prunable THROUGH the composed pipeline too (matcher failure must never + // resolve toward delete; the accepted residual is that leftover records keep + // protecting until an instance is identified again). + const std::vector refsNoneIdentified = mergeReferenced( bankRefs, usageHeldPaths(records, std::unordered_set{}, false)); + const std::vector 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 refsAfterDelete = mergeReferenced( + bankRefs, + usageHeldPaths(records, std::unordered_set{"{OTHER}"}, true)); CHECK(pruneOrphans(present, refsAfterDelete, owned).size() == 2); } @@ -247,11 +467,19 @@ int main() { testDecodeMalformed(); testPlanFreshKey(); testPlanCleanReplaceAndSkip(); - testPlanSameTrackUnion(); + testSiblingCollisionNeverDropsHolds(); + testPlanUnionSkipOnlyWhenAlreadyPoisoned(); + testPlanEmptyNonceNeverClaimsOwnership(); testPlanCrossTrackRemint(); + testPlanOwnRecordAfterTrackMove(); testPlanUndecodableExisting(); testHeldPathsLiveness(); + testZeroIdentifiedProtectsAll(); testHeldPathsDedupAndEmptyPathSkip(); + testTakeFxAttributedRecordIsProtected(); + testUnreadableRecordAbortsPrune(); + testIdentityMatcher(); + testInstanceHoldMakesPathUnprunable(); testMergeReferencedOrderAndDedup(); From ec83f14738a5179d6401df34f6fa7fa854595fe7 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 13:54:10 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(pS-usage):=20close=20delete-ward=20resi?= =?UTF-8?q?duals=20=E2=80=94=20remint=20on=20corrupt,=20named=20abort=20ke?= =?UTF-8?q?ys,=20truncation=20protect-all,=20abort->protect-all=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/actions.cpp | 16 ++++-- src/persist.cpp | 7 ++- src/prune_reconcile.h | 8 +++ src/sample_usage.cpp | 38 +++++++++++--- src/sample_usage.h | 18 +++++-- src/usage_scan.cpp | 35 ++++++++++--- src/usage_scan.h | 10 +++- tests/test_sample_usage.cpp | 101 ++++++++++++++++++++++++++++++++---- 8 files changed, 197 insertions(+), 36 deletions(-) diff --git a/src/actions.cpp b/src/actions.cpp index 84e8ef3..185afea 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -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\", \"\", \"\")\n" + "Offending key(s):\n"; + for (const std::string& key : report.offendingUsageKeys) { + msg += " " + key + "\n"; + } + ShowConsoleMsg(msg.c_str()); return; } diff --git a/src/persist.cpp b/src/persist.cpp index 67af037..d553925 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -317,6 +317,7 @@ struct PruneScan { std::vector orphans; std::unordered_map sizeByRel; bool abortedUnreadableUsage = false; + std::vector 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; } diff --git a/src/prune_reconcile.h b/src/prune_reconcile.h index e90ea7a..a44660c 100644 --- a/src/prune_reconcile.h +++ b/src/prune_reconcile.h @@ -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_" 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", "", "") struct PruneReport { std::size_t count = 0; std::uint64_t totalBytes = 0; std::vector orphans; bool truncated = false; bool abortedUnreadableUsage = false; + std::vector offendingUsageKeys; // non-empty iff abortedUnreadableUsage }; // The outcome of an actual prune DELETION (Phase R, Wave 3 — R3). The prune shell fills diff --git a/src/sample_usage.cpp b/src/sample_usage.cpp index 62dc3ac..8414564 100644 --- a/src/sample_usage.cpp +++ b/src/sample_usage.cpp @@ -145,11 +145,20 @@ UsagePublishPlan planUsagePublish(const std::optional& existing, } const std::optional 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& 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 seen; + for (const std::optional& 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); diff --git a/src/sample_usage.h b/src/sample_usage.h index 7ec8af6..3e90fee 100644 --- a/src/sample_usage.h +++ b/src/sample_usage.h @@ -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). diff --git a/src/usage_scan.cpp b/src/usage_scan.cpp index 53a7bcf..49b91d8 100644 --- a/src/usage_scan.cpp +++ b/src/usage_scan.cpp @@ -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 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(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> 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 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 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; } diff --git a/src/usage_scan.h b/src/usage_scan.h index f5df64c..e9fd5d8 100644 --- a/src/usage_scan.h +++ b/src/usage_scan.h @@ -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_" keys that triggered the abort so the action can +// print them for operator recovery (clear via ReaScript: +// reaper.SetProjExtState(0, "reasampler", "", "") +// 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 offendingKeys; // non-empty iff abortPrune std::vector heldPaths; }; diff --git a/tests/test_sample_usage.cpp b/tests/test_sample_usage.cpp index ec4f595..c2d84b0 100644 --- a/tests/test_sample_usage.cpp +++ b/tests/test_sample_usage.cpp @@ -7,15 +7,20 @@ // // Covers: wire round-trip (nonce + unioned flag, empty / adversarial bytes), // malformed -> nullopt, the publish plan's branches (fresh / clean replace + skip / -// sibling union with the sticky poison flag / cross-track re-mint / undecodable heal), +// sibling union with the sticky poison flag / cross-track re-mint / undecodable remint), // 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. +// (foldUsageRecords.abortPrune — prune halts, deletes nothing), the UNDECODABLE-EXISTING +// REMINT (corrupt key left in place — prune-side abort keeps firing while sibling holds +// unprotected), the ABORT→PROTECT-ALL belt-and-braces (foldUsageRecords.heldPaths is +// the full protect-all set even when abortPrune is set), the TRUNCATED-WALK→PROTECT-ALL +// proof (FX walk misses a nested instance → anyLive=false → usageHeldPaths protects +// every record — the pure side of the depth-cap + depth-exhaustion-is-container fix), +// 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" @@ -254,16 +259,20 @@ static void testPlanOwnRecordAfterTrackMove() { CHECK(back->trackGuid == "{T2}"); } -static void testPlanUndecodableExisting() { - // An undecodable existing value under MY key is corruption — overwrite with mine - // (the self-heal; the prune side independently aborts while it is unreadable). +static void testPlanUndecodableExistingRemints() { + // An undecodable existing value under MY key must REMINT (not overwrite). Overwriting + // would clear the prune-side abort while a same-key sibling B's holds are unprotected + // until B republishes. Leaving the corrupt key in place keeps the prune-side abort + // (foldUsageRecords.abortPrune) firing so no delete-ward window opens. const UsageRecord mine = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}}); const UsagePublishPlan plan = planUsagePublish(std::string("corrupt"), mine); - CHECK(!plan.remint); + CHECK(plan.remint); // fresh key — leave the corrupt key untouched CHECK(!plan.skipWrite); + // wire carries mine (to be written under the NEW key by the caller) auto back = decodeUsageRecord(plan.wire); CHECK(back.has_value()); CHECK(back->holds.size() == 1); + CHECK(!back->unioned); // fresh key, sole writer — un-poisoned } // --- liveness fold --------------------------------------------------------------- @@ -370,6 +379,78 @@ static void testUnreadableRecordAbortsPrune() { CHECK(net.heldPaths.size() == 1); } +// --- abort returns the protect-all set (belt-and-braces) --------------------------- +// foldUsageRecords must return heldPaths = EVERY readable record's paths when +// abortPrune is set, so a future caller that forgets to check the flag before using +// heldPaths still gets maximum protection rather than an empty set (which would be +// delete-ward). +static void testAbortFoldReturnsProtectAllSet() { + // Two readable records + one unreadable (nullopt) in between. + std::vector> decoded; + decoded.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})); + decoded.push_back(std::nullopt); // triggers abort + decoded.push_back(makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}})); + + // Live: only {T1} — so without protect-all, T2's path would be excluded. + const UsageFoldResult fold = + foldUsageRecords(decoded, std::unordered_set{"{T1}"}, true); + CHECK(fold.abortPrune); + // heldPaths must contain BOTH paths (protect-all over all readable records), + // not just {T1}'s path. + CHECK(fold.heldPaths.size() == 2); + bool hasPA = false, hasPB = false; + for (const std::string& p : fold.heldPaths) { + if (p == "pa.wav") hasPA = true; + if (p == "pb.wav") hasPB = true; + } + CHECK(hasPA); + CHECK(hasPB); + + // All nullopt (every key unreadable): abort + empty heldPaths (nothing readable). + std::vector> allNull; + allNull.push_back(std::nullopt); + const UsageFoldResult allNullFold = + foldUsageRecords(allNull, std::unordered_set{}, false); + CHECK(allNullFold.abortPrune); + CHECK(allNullFold.heldPaths.empty()); // no readable records to protect +} + +// --- truncated enumeration → protect-all ------------------------------------------- +// The FX walk may truncate at kMaxContainerDepth, leaving a deeply-nested live instance +// missed. At the PURE layer this is indistinguishable from a genuine identity-matcher +// failure: anyInstanceLive stays false while records exist. The protect-all net in +// usageHeldPaths guarantees this resolves toward PROTECT, never toward delete — the same +// test shape as testZeroIdentifiedProtectsAll, stated here explicitly for the truncation +// failure mode. +static void testTruncatedWalkProtectsAll() { + // Records from two tracks that host instances; the FX walk (shell side) failed to + // identify ANY instance (e.g. truncated at depth, or a future matcher gap). + const std::vector records = { + makeRecord("{TRACK-A}", "N1", {UsageHold{"x", "nested-a.wav"}}), + makeRecord("{TRACK-B}", "N2", {UsageHold{"y", "nested-b.wav"}}), + }; + // Shell reported anyLive=false (it couldn't identify any instance — truncated walk). + const std::vector paths = + usageHeldPaths(records, std::unordered_set{}, /*anyInstanceLive=*/false); + // Both paths must be protected — the protect-all net fires. + CHECK(paths.size() == 2); + bool hasA = false, hasB = false; + for (const std::string& p : paths) { + if (p == "nested-a.wav") hasA = true; + if (p == "nested-b.wav") hasB = true; + } + CHECK(hasA); + CHECK(hasB); + + // Belt-and-braces: the same scenario through foldUsageRecords also protects all. + std::vector> decoded; + for (const UsageRecord& r : records) decoded.push_back(r); + const UsageFoldResult fold = + foldUsageRecords(decoded, std::unordered_set{}, false); + CHECK(!fold.abortPrune); + CHECK(fold.heldPaths.size() == 2); +} + // --- 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 @@ -472,12 +553,14 @@ int main() { testPlanEmptyNonceNeverClaimsOwnership(); testPlanCrossTrackRemint(); testPlanOwnRecordAfterTrackMove(); - testPlanUndecodableExisting(); + testPlanUndecodableExistingRemints(); testHeldPathsLiveness(); testZeroIdentifiedProtectsAll(); testHeldPathsDedupAndEmptyPathSkip(); testTakeFxAttributedRecordIsProtected(); testUnreadableRecordAbortsPrune(); + testAbortFoldReturnsProtectAllSet(); + testTruncatedWalkProtectsAll(); testIdentityMatcher(); testInstanceHoldMakesPathUnprunable();