tracking: one ledger, one authority — prune protection and replace-vs-add answered from the same records, fail-safe on unreadable state

This commit is contained in:
2026-07-30 19:44:11 -04:00
parent 7bd911d58b
commit 7f70d94228
40 changed files with 1546 additions and 633 deletions
+3 -2
View File
@@ -26,11 +26,12 @@ is owned by other directories and only skinned here.
one Ctrl-Z.
- **The prune action is the ONLY file-deletion action in the system**; it opens no
undo point (file deletion is not REAPER-undoable). It halts on
`abortedUnreadableUsage` and prints the offending `rsusage_*` key names.
`blockedByTracking` and prints whichever blockers fired — the malformed ledger,
the offending `rsusage_*` key names, or both.
## Modules
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). **pS-usage:** `BANK_PRUNE_FOLDER` halts on `abortedUnreadableUsage` and prints the offending `rsusage_*` key names with clear instructions.
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
- `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`.
- `instrument_drop_win` — FX-button drop shell: resolves a screen point to a track + FX-surface hotspot, then adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
- `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.**
+2 -2
View File
@@ -273,9 +273,9 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) {
s.createdTimestamp = nowSec;
const AddResult r = book.activeIndex().add(s);
// Record as owned regardless of outcome — the tool WROTE the file, so prune must
// Record the birth regardless of outcome — the tool WROTE the file, so prune must
// attribute it even in the narrow Collapsed race below.
g_session->owned().add(paths.relativePath);
g_session->recordCreated(s, tracking::OriginKind::Ingest);
switch (r) {
case AddResult::Added:
+23 -13
View File
@@ -23,20 +23,30 @@ namespace reasampler {
void doBankPruneFolder(ReaSamplerSession& session) {
const reclaim::PruneReport report = session.pruneDryRun();
// FAIL-SAFE: an unreadable instance-usage record makes the protected set
// unknowable, so the prune HALTS outright rather than proceed with degraded
// protection.
if (report.abortedUnreadableUsage) {
// FAIL-SAFE: tracking state the authority could not read makes the protected
// set unknowable, so the prune HALTS outright rather than proceed with degraded
// protection. Both blockers can fire at once; report each one that did.
if (report.blockedByTracking) {
std::string msg =
"ReaSampler prune: ABORTED -- one or more instance usage records could not "
"be read or decoded. Nothing was deleted.\n"
"If the owning instance is still loaded it will republish its record on the "
"next poll tick, clearing the abort. If the instance no longer exists (the "
"key is an orphaned corrupt record), clear it manually via ReaScript:\n"
" reaper.SetProjExtState(0, \"reasampler\", \"<key>\", \"\")\n"
"Offending key(s):\n";
for (const std::string& key : report.offendingUsageKeys) {
msg += " " + key + "\n";
"ReaSampler prune: ABORTED -- the file-tracking state could not be read. "
"Nothing was deleted.\n";
if (report.ledgerUnreadable) {
msg += "The stored file-tracking ledger is malformed. It has been left "
"intact rather than overwritten, so it can be repaired or cleared:\n"
" reaper.SetProjExtState(0, \"reasampler\", \"owned_files\", \"\")\n"
"Clearing it makes every existing bank file un-reclaimable (they stop "
"being attributable to ReaSampler); no file is lost.\n";
}
if (!report.unreadableUsageKeys.empty()) {
msg += "One or more instance usage records could not be read or decoded. "
"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:\n"
" reaper.SetProjExtState(0, \"reasampler\", \"<key>\", \"\")\n"
"Offending key(s):\n";
for (const std::string& key : report.unreadableUsageKeys) {
msg += " " + key + "\n";
}
}
ShowConsoleMsg(msg.c_str());
return;
+3 -3
View File
@@ -4,9 +4,9 @@
// module. Registration/dispatch for its FOREVER-STABLE id (BANK_PRUNE_FOLDER) stay
// with bank_actions; one guarded body here.
//
// Contract (preserve exactly): dry-run first; abort outright on unreadable usage
// records (fail-safe); confirm-with-manifest before any deletion; opens NO undo
// point and writes NO ext state (file deletion is not REAPER-undoable).
// Contract (preserve exactly): dry-run first; abort outright when the tracking
// authority reports a block (fail-safe); confirm-with-manifest before any deletion;
// opens NO undo point and writes NO ext state (file deletion is not REAPER-undoable).
namespace reasampler {
+1 -1
View File
@@ -36,7 +36,7 @@ detail not covered there:
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain).
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + owned-manifest record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26).
+5 -4
View File
@@ -458,14 +458,15 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
const bool changed = session.book().updateSampleInPlace(sampleId, updated);
if (changed)
{
// Record the regenerated file in the owned manifest; the superseded file
// becomes an orphan for prune to reclaim.
session.owned().add(updated.relativePath);
// Record the regenerated file's birth; the superseded file becomes an orphan
// for prune to reclaim. A re-capture onto the same path is a dedup no-op, so
// the original birth record — not this one — stays authoritative.
session.recordCreated(updated, tracking::OriginKind::Recapture);
// Regenerating the same id's audio is exactly why instances need the generation
// bump — they'd otherwise keep playing stale audio until reload. Bumped inside
// the undo block so undo rolls back the generation with the rest of the blob.
session.bumpBankGeneration();
const bool persisted = session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty
const bool persisted = session.saveToActiveProject(); // book + ledger + generation + MarkProjectDirty
Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "",
persisted ? UNDO_STATE_MISCCFG : 0);
}
+7 -8
View File
@@ -188,7 +188,7 @@ CaptureResult renderOffline(CaptureScope scope,
// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance,
// and adds the resulting Sample to the ACTIVE bank + records the created file in the
// owned-file manifest — WITHOUT persisting. The caller persists once (single-capture:
// tracking ledger — WITHOUT persisting. The caller persists once (single-capture:
// right after; batch: once at the end) so a batch does not write ext state N times.
//
// Provenance is read from the LIVE selection here, so a batch that transiently
@@ -250,11 +250,10 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session,
// AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can
// target the sample actually in the bank (the existing entry on a collapse).
const model::AddResult addResult = session.bank().add(res.sample);
// B-cap: record the created file in the owned-file manifest, at the same point the
// Sample is added. Recorded regardless of the index AddResult — even a hash-collapse
// still WROTE a file the tool owns, and the manifest dedups a repeat path itself
// (Phase R prune reconciles manifest vs index later).
session.owned().add(res.sample.relativePath);
// Record the birth at the same point the Sample is added, regardless of the index
// AddResult — even a hash-collapse still WROTE a file the tool owns, and the ledger
// dedups a repeat path itself (prune reconciles ledger vs index later).
session.recordCreated(res.sample, tracking::OriginKind::Capture);
// Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new
// id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a
@@ -297,8 +296,8 @@ std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def)
}
// captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE
// bank, and recorded the created file in the owned-file manifest (WITHOUT persisting).
// Persist the updated book AND manifest into the active project's ext state (the
// bank, and recorded the created file in the tracking ledger (WITHOUT persisting).
// Persist the updated book AND ledger into the active project's ext state (the
// `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and
// travels with the .rpp. saveToActiveProject also clears the retired legacy key and
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
+2 -2
View File
@@ -2,7 +2,7 @@
// Single-capture orchestration + the realtime/insert action bodies: renderOffline
// (one offline render under the scope's FxBypassGuard, shared by single-shot/
// batch/recapture), captureAndIndexOne (render + provenance + bank add +
// owned-manifest record, unpersisted), RunCapture/RunCaptureItemAssign,
// tracking-ledger record, unpersisted), RunCapture/RunCaptureItemAssign,
// RunCaptureRealtimeTrack/RunCancelRealtime (in-flight state lives in
// realtime_lifecycle), and RunInsertSelected — the one deliberate exception to
// capture-never-places.
@@ -34,7 +34,7 @@ CaptureResult renderOffline(CaptureScope scope,
const CaptureRequest& req);
// Renders one capture request, stamps provenance, adds the Sample to the
// active bank + owned-file manifest — without persisting (batch persists once
// active bank + tracking ledger — without persisting (batch persists once
// at the end). res.sample.id carries the landed bank-index id (fresh add or
// hash-dedup collapse target).
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
+4 -5
View File
@@ -31,14 +31,13 @@ void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res)
return;
}
session.bank().add(res.sample);
// Record the file in the owned manifest regardless of the index AddResult — even
// a hash-collapse still wrote a file the tool owns; the manifest dedups a repeat
// path itself (prune reconciles manifest vs index).
session.owned().add(res.sample.relativePath);
// Record the birth regardless of the index AddResult — even a hash-collapse still
// wrote a file the tool owns; the ledger dedups a repeat path itself.
session.recordCreated(res.sample, tracking::OriginKind::Capture);
// A capture add changes what a live instance could play, so bump the generation
// before persisting to refresh instances.
session.bumpBankGeneration();
session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty
session.saveToActiveProject(); // persist book + ledger + generation + MarkProjectDirty
}
// Advances any in-flight realtime capture one tick. Detects a project switch
+29 -24
View File
@@ -4,10 +4,11 @@
The persist seam: project ext-state read/write (`session` / `ext_state_io`), the
prune path's filesystem half (`prune_fs`), and the extension-side instance-usage
scan (`usage_scan`) that feeds prune's referenced-set. Internal helpers shared only
within the persist TU family live in `persist_internal.h`. The pure orphan
computation is owned elsewhere (`core/reclaim`); the pure usage wire is owned
elsewhere (`core/wire`) — this directory is the REAPER/filesystem-facing half only.
scan (`usage_scan`). Internal helpers shared only within the persist TU family live
in `persist_internal.h`. The pure orphan computation is owned elsewhere
(`core/reclaim`), the pure usage wire elsewhere again (`core/wire`), and every
tracking *decision* by `core/tracking`'s authority — this directory is the
REAPER/filesystem-facing half only, and it gathers rather than decides.
## Invariants
@@ -18,34 +19,37 @@ elsewhere (`core/wire`) — this directory is the REAPER/filesystem-facing half
(orphan count, reclaimed size, and — for a small set — the files); actual
deletion is a confirmed second step. No periodic/background sweep.
- **Referenced-set is the union across ALL banks, pool included**, further unioned
(pS-usage) with every live instance's held paths via `usage_scan`
`prune_reconcile::mergeReferenced`. A file is an orphan iff no bank AND no live
instance references it.
with every live instance's held paths — supplied by `tracking::pruneProtection`,
never assembled here — via `prune_reconcile::mergeReferenced`. A file is an orphan
iff no bank AND no live instance references it.
- **Safest platform deletion available.** Trash-preferred, unlink fallback — Windows
routes through `SHFileOperationW` (`FOF_ALLOWUNDO`, verified against SDK
10.0.26100); macOS/Linux fall back to unlink (no portable SWELL trash surface).
`prune_fs` is the only module that calls this.
- **Manual, explicit trigger only** — a bindable action + a `bank_panel` button,
never a silent background sweep.
- **Instance-usage fail-safe (pS-usage):** a capture held by any live ReaSampler
9000 instance can never be deleted by prune. If any `rsusage_*` record is
unreadable or ambiguous, prune **aborts entirely and deletes nothing**
over-protection is the accepted residual, under-protection is a data-loss bug.
`usage_scan` decodes every `rsusage_*` key, enumerates every ReaSampler 9000 FX
instance (all tracks incl. master, normal + record/input chains, containers
recursively, take FX), and folds via the pure `sample_usage::foldUsageRecords` /
`usageHeldPaths` (a record with no live instance context protects all its paths —
identity-failure net, never degrades toward delete). This is read-only at
prune-scan time: `usage_scan` writes no ext-state.
- **`PruneReport` carries `abortedUnreadableUsage` + `offendingUsageKeys`**; dry-run,
orphan-set, and reclaim each independently abort (delete nothing) when usage
state is unreadable. `BANK_PRUNE_FOLDER` (in `shell/actions`) halts on this flag
and prints the offending keys.
- **Instance-usage fail-safe:** a capture held by any live ReaSampler 9000 instance
can never be deleted by prune. `usage_scan` decodes every `rsusage_*` key,
enumerates every ReaSampler 9000 FX instance (all tracks incl. master, normal +
record/input chains, containers recursively, take FX), and folds via the pure
`sample_usage::foldUsageRecords`. Read-only at prune-scan time: `usage_scan` writes
no ext-state.
- **A malformed tracking ledger is Unreadable, never "empty".** `ext_state_io` keeps
the `LedgerStatus` alongside the ledger, and `saveToActiveProject` SKIPS the
`owned_files` write while it is `Unreadable` — replacing a corrupt blob would
destroy the only record of every file created before the corruption, silently
turning them into permanently unreclaimable foreign files. Captures made during
such a session are recorded in memory but not persisted; they degrade to foreign
(untouchable), which is the safe direction.
- **`PruneReport` carries `blockedByTracking` + `ledgerUnreadable` /
`unreadableUsageKeys`**; dry-run, orphan-set, and reclaim each independently abort
(delete nothing) on a block. `BANK_PRUNE_FOLDER` (in `shell/actions`) halts on the
flag and prints whichever blockers fired.
## Modules
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, `OwnedManifest` JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `prune_fs` hosts the prune dry-run / full-set orphan queries (supplying `referencedPaths()` + `owned().paths()` to the `prune_reconcile` pure core) and is **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. **pS-usage:** the prune scan unions instance usage via `usage_scan`; `PruneReport` carries `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) when usage state is unreadable.
- `usage_scan` — extension-side prune-scan shell (pS-usage): at prune-scan time, enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and folds with `sample_usage::foldUsageRecords` / `usageHeldPaths` to produce the set of held paths — or `abortPrune` when any record is unreadable (fail-safe: an unreadable record may protect anything, so the prune halts). Feeds `prune_reconcile::mergeReferenced`. Read-only: writes no ext-state.
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `session` additionally owns `recordCreated`**the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) and is **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
- `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state.
- `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses.
## Gotchas
@@ -55,6 +59,7 @@ elsewhere (`core/wire`) — this directory is the REAPER/filesystem-facing half
file.
- The pure usage wire (`sample_usage`: `UsageRecord`, `planUsagePublish`,
`foldUsageRecords`/`usageHeldPaths`, `identityMatches`) is documented under
`core/wire`, not here.
`core/wire`, and the ledger + the two consumer answers under `core/tracking`
neither belongs in this file.
- `persist_internal.h` is an internal seam, not a public header — do not include it
outside `session.cpp` / `ext_state_io.cpp` / `prune_fs.cpp`.
+26 -20
View File
@@ -161,10 +161,15 @@ bool ReaSamplerSession::saveToActiveProject() {
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtTailKey, tailJson.c_str());
// Written on every save so the manifest and the bank stay in lockstep on disk.
const std::string ownedJson = owned_.serialize();
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtOwnedKey, ownedJson.c_str());
// Written on every save so the ledger and the bank stay in lockstep on disk
// EXCEPT over a blob we could not read: replacing it would destroy the only
// record of every file created before the corruption, silently turning them
// into permanently unreclaimable foreign files.
if (trackingStatus_ != tracking::LedgerStatus::Unreadable) {
const std::string ledgerJson = tracking_.serialize();
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtOwnedKey, ledgerJson.c_str());
}
// stampVersion() (not appVersion()) is the numeric triple only, no "-beta"
// suffix, so the stamp is byte-identical to stable regardless of channel
@@ -229,21 +234,20 @@ capture::TailSetting loadTailSetting(ReaProject* proj) {
return *loaded;
}
// Absent/empty key -> empty manifest. Malformed JSON warns and falls back to
// empty; prune then attributes nothing until the next capture rebuilds it —
// degrades safety, never correctness.
model::OwnedFileManifest loadOwnedManifest(ReaProject* proj) {
if (!proj) return model::OwnedFileManifest{};
const std::string ownedJson =
getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey);
if (ownedJson.empty()) return model::OwnedFileManifest{}; // no stored manifest -> empty
std::optional<model::OwnedFileManifest> loaded =
model::OwnedFileManifest::deserialize(ownedJson);
if (!loaded) {
ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed -- ignoring.\n");
return model::OwnedFileManifest{};
// Absent/empty key -> Fresh (a new project, or a bank predating the ledger).
// Malformed -> Unreadable, which halts the prune and suppresses the next write
// rather than degrading to an empty ledger that looks like "nothing was ever
// created".
tracking::LedgerLoad loadOriginLedger(ReaProject* proj) {
if (!proj) return tracking::LedgerLoad{};
tracking::LedgerLoad load = tracking::loadLedger(
getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey));
if (load.status == tracking::LedgerStatus::Unreadable) {
ShowConsoleMsg("ReaSampler: the stored file-tracking ledger is malformed. Prune "
"is halted for this project and the stored value is left intact "
"for recovery.\n");
}
return std::move(*loaded);
return load;
}
} // namespace
@@ -255,13 +259,15 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
// consumeLoadSignal() on the same tick.
loadPending_ = true;
// view_/tail_/owned_ are all restored on EVERY load path: switching to a
// view_/tail_/tracking_ are all restored on EVERY load path: switching to a
// project with no stored state must reset to default, never inherit the
// previous project's. An undo/redo reload must re-read the restored
// values so they match the rolled-back state.
view_ = loadViewModel(static_cast<ReaProject*>(proj));
tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
owned_ = loadOwnedManifest(static_cast<ReaProject*>(proj));
tracking::LedgerLoad ledger = loadOriginLedger(static_cast<ReaProject*>(proj));
trackingStatus_ = ledger.status;
tracking_ = std::move(ledger.ledger);
// An absent stamp classifies as PreVersioning, a malformed one as Unknown
// — both silent. proj == nullptr -> "" -> default.
+44 -37
View File
@@ -33,10 +33,11 @@
#include "shell/persist/persist_internal.h"
#include "shell/persist/session.h"
#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths — instance holds join `referenced`
#include "shell/persist/usage_scan.h" // scanInstanceUsage — one of the authority's two inputs
#include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder
#include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies
#include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder
#include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies
#include "core/tracking/tracking_authority.h" // the one protection answer
namespace reasampler {
@@ -62,21 +63,22 @@ constexpr std::size_t kPruneListDisplayCap = 64;
// active/saved project, no project dir, or no folder yet.
// * orphans — the full orphan set, untruncated. The pure core decides.
// * sizeByRel — per-orphan on-disk byte size (0 when it could not be stat'd).
// * abortedUnreadableUsage — true iff a present rsusage_* record could not
// be read/decoded: `orphans` is left EMPTY, the prune must
// halt rather than proceed with degraded protection.
// * blocked — true iff the tracking authority could not answer: `orphans`
// is left EMPTY, the prune must halt rather than proceed with
// degraded protection.
struct PruneScan {
std::string bankDirAbs;
std::vector<std::string> orphans;
std::unordered_map<std::string, std::uint64_t> sizeByRel;
bool abortedUnreadableUsage = false;
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
bool blocked = false;
bool ledgerUnreadable = false;
std::vector<std::string> unreadableUsageKeys;
};
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI.
PruneScan scanPruneOrphans(const BankBook& book,
const model::OwnedFileManifest& owned) {
PruneScan scanPruneOrphans(const BankBook& book, const tracking::OriginLedger& ledger,
tracking::LedgerStatus ledgerStatus) {
PruneScan scan;
std::string rppPath;
@@ -97,7 +99,7 @@ PruneScan scanPruneOrphans(const BankBook& book,
// Enumerate into project-relative paths spelled the SAME way the capture
// path spells them, so the pure core's exact-string match lines up with
// referencedPaths() and the manifest. Non-recursive: the bank folder is
// referencedPaths() and the ledger. Non-recursive: the bank folder is
// flat. Manual iterator form (it.increment(ec)) keeps the loop
// non-throwing on a mid-iteration failure.
std::vector<std::string> present;
@@ -115,28 +117,30 @@ PruneScan scanPruneOrphans(const BankBook& book,
scan.sizeByRel[rel] = sz_ec ? 0 : static_cast<std::uint64_t>(sz);
}
// The decision lives in the pure core — read-only inputs from the book and
// manifest. referencedPaths() unions across the whole book; the referenced
// set additionally unions every LIVE ReaSampler 9000 instance's held
// captures (usage_scan + sample_usage decide liveness) — a capture any
// live instance holds can never be an orphan, even if its bank entry was
// deleted while the instance kept its ref. liveInstanceHeldPaths is
// read-only; this shell only enumerates, resolves, and stats.
// The decision lives in the pure core; the tracking authority supplies both of
// its tracking-derived inputs so the prune and the resample can never disagree
// about what is protected. referencedPaths() unions across the whole book; the
// authority's heldPaths adds every live instance's captures on top — a capture
// any live instance holds can never be an orphan, even if its bank entry was
// deleted while the instance kept its ref. Read-only throughout: 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 then deletes nothing. The key names let the
// action tell the user which keys to recover.
scan.abortedUnreadableUsage = true;
scan.offendingUsageKeys = usage.offendingKeys;
const wire::UsageFoldResult usage = scanInstanceUsage(proj);
const tracking::TrackingState state{ledgerStatus, ledger, usage};
const tracking::ProtectionAnswer protection = tracking::pruneProtection(state);
if (protection.blocked) {
// FAIL-SAFE ABORT: the protected set is unknowable. Compute NO orphans —
// every downstream consumer then deletes nothing. The blockers let the
// action tell the user what to recover.
scan.blocked = true;
scan.ledgerUnreadable = protection.ledgerUnreadable;
scan.unreadableUsageKeys = protection.unreadableUsageKeys;
return scan;
}
scan.orphans = reclaim::pruneOrphans(
present,
reclaim::mergeReferenced(book.referencedPaths(), usage.heldPaths),
owned.paths());
reclaim::mergeReferenced(book.referencedPaths(), protection.heldPaths),
protection.ownedPaths);
return scan;
}
@@ -199,19 +203,22 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
} // namespace
reclaim::PruneReport ReaSamplerSession::pruneDryRun() const {
const PruneScan scan = scanPruneOrphans(book_, owned_);
const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_);
reclaim::PruneReport report =
reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
// Surface the unreadable-usage abort so the action halts with an explicit
// message instead of reporting "no orphaned files" — the count IS zero,
// but the user must know the prune refused to run.
report.abortedUnreadableUsage = scan.abortedUnreadableUsage;
report.offendingUsageKeys = scan.offendingUsageKeys;
// Surface the block so the action halts with an explicit message instead of
// reporting "no orphaned files" — the count IS zero, but the user must know
// the prune refused to run.
report.blockedByTracking = scan.blocked;
report.ledgerUnreadable = scan.ledgerUnreadable;
report.unreadableUsageKeys = scan.unreadableUsageKeys;
return report;
}
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
return scanPruneOrphans(book_, owned_).orphans; // full set, untruncated
// Full set, untruncated; empty on a block, so a caller that skipped the report
// still confirms nothing.
return scanPruneOrphans(book_, tracking_, trackingStatus_).orphans;
}
reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim(
@@ -222,10 +229,10 @@ reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim(
// targets exactly `confirmed ∩ freshOrphans`, so a file that vanished or
// became referenced between confirm and delete is skipped, and a newly-
// appeared orphan not in `confirmed` is never swept. If this fresh scan
// hits an unreadable usage record it aborts with an EMPTY orphan set, so
// hits unreadable tracking state 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-to-delete window.
const PruneScan scan = scanPruneOrphans(book_, owned_);
const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_);
if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing
const std::vector<std::string> plan =
+13
View File
@@ -46,6 +46,19 @@ using persist_detail::projectDirOf;
using persist_detail::readActiveProject;
using persist_detail::relocateBankFolder;
void ReaSamplerSession::recordCreated(const model::Sample& sample,
tracking::OriginKind kind) {
tracking::OriginRecord rec;
rec.relativePath = sample.relativePath;
rec.kind = kind;
rec.sampleId = sample.id;
// The Sample's own provenance is where the parent was resolved; reading it here
// rather than re-deriving keeps one source for the lineage fact. Absent
// provenance is a root capture, not a gap.
if (sample.provenance) rec.parentSampleId = sample.provenance->parentSampleId;
tracking_.record(rec);
}
bool ReaSamplerSession::consumeLoadSignal() {
const bool pending = loadPending_;
loadPending_ = false;
+24 -12
View File
@@ -26,8 +26,8 @@
#include "core/capture/tail_control.h"
#include "core/model/bank_book.h"
#include "core/model/bank_model.h"
#include "core/model/owned_manifest.h"
#include "core/reclaim/prune_reconcile.h"
#include "core/tracking/origin_ledger.h"
#include "core/version/app_version.h"
#include "core/view/view_mode_model.h"
@@ -71,9 +71,18 @@ public:
capture::TailSetting& tail() { return tail_; }
const capture::TailSetting& tail() const { return tail_; }
// Project-relative files the capture path itself created; prune consumes it.
model::OwnedFileManifest& owned() { return owned_; }
const model::OwnedFileManifest& owned() const { return owned_; }
// Birth records for the files the system itself created. Read as a PAIR —
// the ledger alone cannot say whether an absent record means never-recorded
// or unreadable, and the two demand opposite treatment. This is the reach
// any consumer outside persist uses to build a tracking::TrackingState.
const tracking::OriginLedger& tracking() const { return tracking_; }
tracking::LedgerStatus trackingStatus() const { return trackingStatus_; }
// Record a system-created file at the moment it exists — the ONLY way a
// birth record is written, so lineage can never be backfilled from a later
// guess. Lineage is read off the Sample's own provenance, the same act that
// stamped it, so the two cannot disagree. A repeat path is a no-op.
void recordCreated(const model::Sample& sample, tracking::OriginKind kind);
// The version that last wrote the active project: PreVersioning (no
// stamp), Unknown (malformed), or Stamped.
@@ -93,11 +102,10 @@ public:
// a persist happened, so a caller can skip an undo block when nothing was written.
bool saveToActiveProject();
// Report-only prune dry-run: feeds the pure core with (present,
// referenced, owned), where `referenced` = book references union every
// live instance's held captures (usage_scan + sample_usage decide
// liveness). FAIL-SAFE: an unreadable usage record sets
// abortedUnreadableUsage with an EMPTY orphan set. Read-only throughout.
// Report-only prune dry-run: feeds the pure core with (present, referenced,
// owned) — `present` from the folder enumeration, the other two from the
// tracking authority. FAIL-SAFE: tracking state the authority cannot read
// sets blockedByTracking with an EMPTY orphan set. Read-only throughout.
reclaim::PruneReport pruneDryRun() const;
// The full (untruncated) orphan set, same compute as pruneDryRun. The
@@ -110,7 +118,7 @@ public:
// file that vanished or became referenced since confirm is skipped, and
// an orphan the user did not see is never swept. Trash-preferred
// (Windows Recycle Bin; unlink elsewhere). Does not modify the book or
// OwnedFileManifest, writes no ext-state. No-ops when nothing to delete;
// the ledger, writes no ext-state. No-ops when nothing to delete;
// does not prompt.
reclaim::PruneDeletionResult pruneReclaim(
const std::vector<std::string>& confirmed) const;
@@ -144,7 +152,11 @@ private:
BankBook book_;
ViewModeModel view_; // reset to default on a project with no stored view_state
capture::TailSetting tail_; // reset to default (None / 2s) with no stored tail key
model::OwnedFileManifest owned_; // reset to empty/stored on EVERY load path, never inherited
tracking::OriginLedger tracking_; // reset to empty/stored on EVERY load path, never inherited
// Unreadable is sticky for the project's session: it halts the prune AND
// suppresses the ledger write, so a corrupt blob survives for recovery
// instead of being silently replaced by a ledger missing every earlier file.
tracking::LedgerStatus trackingStatus_ = tracking::LedgerStatus::Fresh;
version::WritingVersion writingVersion_; // recovered per load; PreVersioning default
std::int64_t bankGeneration_ = 0; // recovered per load (absent -> 0); monotonic
@@ -159,7 +171,7 @@ private:
bool reloadRequested_ = false; // raised by requestReload; drained by poll
// Load the book from `proj`'s ext state (`banks`, else legacy
// `bank_index` migrated into the pool); also restores view_/tail_/owned_.
// `bank_index` migrated into the pool); also restores view_/tail_/tracking_.
void loadFromProject(void* proj, const std::string& projectDir);
};
+9 -18
View File
@@ -178,9 +178,9 @@ std::optional<std::string> readExtStateValue(ReaProject* proj, const char* key)
} // namespace
UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
UsageFoldResult scanInstanceUsage(void* projOpaque) {
ReaProject* proj = static_cast<ReaProject*>(projOpaque);
UsageScanResult result;
UsageFoldResult result;
// Enumerate rsusage_* keys, then read+decode via the growing reader
// (EnumProjExtState's fixed val buffer could truncate a large record).
@@ -199,19 +199,14 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
}
if (usageKeys.empty()) return result; // no instance ever published — skip the scan
std::vector<std::optional<UsageRecord>> decoded;
std::vector<DecodedUsage> decoded;
decoded.reserve(usageKeys.size());
for (std::size_t ki = 0; ki < usageKeys.size(); ++ki) {
const std::string& key = usageKeys[ki];
for (const std::string& key : usageKeys) {
const std::optional<std::string> value = readExtStateValue(proj, key.c_str());
if (!value) {
decoded.push_back(std::nullopt); // unreadable -> abort (pure fold)
result.offendingKeys.push_back(key);
continue;
}
const std::optional<UsageRecord> rec = decodeUsageRecord(*value);
if (!rec) result.offendingKeys.push_back(key);
decoded.push_back(rec); // undecodable nullopt -> abort
// Unreadable or undecodable both land as a nullopt record; the pure fold
// turns either into the abort and names the key.
decoded.push_back(DecodedUsage{
key, value ? decodeUsageRecord(*value) : std::nullopt});
}
// Enumerate live ReaSampler 9000 hosts; a track needs only one instance to
@@ -254,11 +249,7 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
// 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;
if (!result.abortPrune) result.offendingKeys.clear(); // only meaningful on abort
return result;
return foldUsageRecords(decoded, liveTrackGuids, anyLive);
}
} // namespace reasampler
+8 -29
View File
@@ -1,45 +1,24 @@
#pragma once
// usage_scan — the extension-side shell of the instance-usage seam (see
// sample_usage.h for the pure core and fail-safe folds). 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: (1) enumerate every "rsusage_<guid>" key and decode
// each record — unreadable/undecodable folds to abortPrune; (2) enumerate
// usage_scan — the extension-side shell of the instance-usage facet (see
// sample_usage.h for the pure core and its fail-safe folds). Three reads, no
// writes: enumerate every "rsusage_<guid>" key and decode each record; enumerate
// every ReaSampler 9000 FX instance (all tracks incl. master, normal +
// record/input chains, containers recursively, take FX) via
// sample_usage::identityMatches; (3) fold with the pure liveness rule — zero
// instances identified anywhere protects every record's paths.
// sample_usage::identityMatches; fold with the pure liveness rule.
//
// Feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans — a
// held capture can never be an orphan. abortPrune propagates to the action,
// which halts.
// The result is one of the two inputs tracking_authority reads — this shell
// gathers, it decides nothing.
//
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers). The header stays
// REAPER-free (`proj` is the opaque ReaProject* passed as void*).
#include <string>
#include <vector>
#include "core/wire/sample_usage.h"
namespace reasampler {
// When abortPrune is true, a present rsusage_* record could not be read or
// decoded — the caller MUST halt the prune. offendingKeys names the exact
// keys that triggered the abort, so the action can print them for recovery
// (clear via ReaScript: reaper.SetProjExtState(0, "reasampler", "<key>", "")).
// heldPaths on abort is the protect-all set — a belt-and-braces fallback; the
// abort flag is authoritative. Otherwise heldPaths is every project-relative
// path held by a live instance, de-duped, in record order.
struct UsageScanResult {
bool abortPrune = false;
std::vector<std::string> offendingKeys; // non-empty iff abortPrune
std::vector<std::string> heldPaths;
};
// Scan `proj` (nullptr = active project). READ-ONLY: no ext-state write, no project
// mutation.
UsageScanResult liveInstanceHeldPaths(void* proj);
wire::UsageFoldResult scanInstanceUsage(void* proj);
} // namespace reasampler