Q-W5: persist → session/ext_state_io/prune_fs (deletion authority concentrated); one GetProjExtState grow-loop in bridge_marshal (T2-04, ×3 rewired); bank_book JSON codec → bank_book_json via private static nameKey; persist.h stays umbrella. 61/61 green.
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler
|
||||
// (Q-W5 split of the former persist.cpp; see session.h for the TU map).
|
||||
//
|
||||
// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove on
|
||||
// SWELL platforms) is the ONLY code in the system that deletes USER files — the sole
|
||||
// deletion authority over the bank folder's bytes (the R3 prune; shells removing a
|
||||
// transient scratch file they themselves just created, e.g. the drop path's temp
|
||||
// .vstpreset, are self-cleanup, not authority over user data). It is deliberately
|
||||
// file-local (anonymous namespace): nothing outside this TU can reach it. The Q-W5
|
||||
// split CONCENTRATES the deletion authority here — it must never
|
||||
// spread (CONTEXT.md §Phase Q deletion-authority isolation;
|
||||
// docs/product/code-organization.md §7). The safety-critical "which files are
|
||||
// orphans" decision stays in the pure core (prune_reconcile); this TU only
|
||||
// enumerates, resolves, stats, and — after the R3 confirm — executes.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. REAPER-facing only through the
|
||||
// persist_detail helpers (active-project read) and usage_scan (the pS-usage
|
||||
// instance-hold reads); this TU itself calls no REAPER API directly.
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is
|
||||
// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK
|
||||
// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... },
|
||||
// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL
|
||||
// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the
|
||||
// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing.
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#endif
|
||||
|
||||
#include "shell/persist/persist_internal.h"
|
||||
#include "shell/persist/session.h"
|
||||
#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`)
|
||||
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder
|
||||
#include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
using persist_detail::projectDirOf;
|
||||
using persist_detail::readActiveProject;
|
||||
|
||||
// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always
|
||||
// exact (tallied over the full orphan set), but the enumerated file list handed to the
|
||||
// console is clipped to this many entries so a project with thousands of orphans does
|
||||
// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can
|
||||
// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling.
|
||||
constexpr std::size_t kPruneListDisplayCap = 64;
|
||||
|
||||
// A fresh enumerate + pure-core prune compute for the active project. Shared by the
|
||||
// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion
|
||||
// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path
|
||||
// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the
|
||||
// active project, enumerates the folder) but writes nothing.
|
||||
//
|
||||
// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty
|
||||
// when there is no active/saved project, no project dir, or no folder on
|
||||
// disk yet -> the caller treats an empty dir as "nothing to reclaim".
|
||||
// * 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<std::string> orphans;
|
||||
std::unordered_map<std::string, std::uint64_t> sizeByRel;
|
||||
bool abortedUnreadableUsage = false;
|
||||
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
|
||||
};
|
||||
|
||||
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
|
||||
// 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 scan;
|
||||
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan
|
||||
|
||||
// Resolve the CURRENT bank folder the same way the index does (M4): project dir of
|
||||
// the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a
|
||||
// Save-As relocation is followed automatically. resolveBankFile is the shared M4
|
||||
// arithmetic; feeding it the bank subfolder as the "relative path" yields the folder.
|
||||
const std::string projectDir = projectDirOf(rppPath);
|
||||
const std::string bankDir =
|
||||
capture::resolveBankFile(projectDir, capture::kBankSubfolder);
|
||||
if (bankDir.empty()) return scan; // unresolvable (no project dir) -> empty scan
|
||||
|
||||
std::error_code ec;
|
||||
if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) {
|
||||
return scan; // no bank folder captured yet -> nothing to reclaim
|
||||
}
|
||||
|
||||
// Enumerate the folder into project-relative index-spelled paths, spelled the SAME
|
||||
// way the capture path spelled them (bankRelativeForName == deriveBankPaths's
|
||||
// convention) so the pure core's exact-string match lines up with referencedPaths()
|
||||
// and the manifest. Non-recursive: the bank folder is flat (capture writes files
|
||||
// directly here); skip any subdirectory. Size is stat'd here and cached by relative
|
||||
// path so the report's byte tally reuses the same on-disk read.
|
||||
// Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration
|
||||
// failure (file removed, permission flip) breaks out with a best-effort partial list
|
||||
// rather than propagating std::filesystem_error across REAPER's C ABI.
|
||||
std::vector<std::string> present;
|
||||
fs::directory_iterator it(bankDir, ec);
|
||||
for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) {
|
||||
const auto& entry = *it;
|
||||
std::error_code reg_ec;
|
||||
if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials
|
||||
const std::string name = entry.path().filename().string();
|
||||
const std::string rel = capture::bankRelativeForName(name);
|
||||
if (rel.empty()) continue;
|
||||
present.push_back(rel);
|
||||
std::error_code sz_ec;
|
||||
const std::uintmax_t sz = entry.file_size(sz_ec);
|
||||
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 (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
|
||||
// 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 + 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 = reclaim::pruneOrphans(
|
||||
present,
|
||||
reclaim::mergeReferenced(book.referencedPaths(), usage.heldPaths),
|
||||
owned.paths());
|
||||
return scan;
|
||||
}
|
||||
|
||||
// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the
|
||||
// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases:
|
||||
// * `outAlreadyAbsent` set true — the file was already gone before we touched it;
|
||||
// the caller folds this into the stale/staleness tally, NOT reclaimedCount.
|
||||
// * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error);
|
||||
// the caller folds this into skippedCount.
|
||||
// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception
|
||||
// may cross the C ABI.
|
||||
//
|
||||
// Per-platform routing:
|
||||
// * Windows — SHFileOperationW(FO_DELETE, pFrom=<double-NUL path>, FOF_ALLOWUNDO |
|
||||
// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the
|
||||
// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our
|
||||
// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true.
|
||||
// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this
|
||||
// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3
|
||||
// confirm guardrail. `outUsedTrash` left as-is (false).
|
||||
bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
|
||||
bool& outAlreadyAbsent) {
|
||||
#ifdef _WIN32
|
||||
// Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string.
|
||||
// SHFileOperation's pFrom is a list; a single path still needs the extra terminating
|
||||
// NUL. Backslashes are required (shell APIs reject forward slashes in some cases).
|
||||
std::string win = absPath;
|
||||
for (char& c : win) if (c == '/') c = '\\';
|
||||
|
||||
const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0);
|
||||
if (wlen <= 0) return false; // conversion failed -> real skip (outAlreadyAbsent stays false)
|
||||
std::vector<wchar_t> wbuf(static_cast<std::size_t>(wlen) + 1, L'\0'); // +1 for list NUL
|
||||
MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen);
|
||||
// wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen]
|
||||
// makes it the double-NUL-terminated single-element list SHFileOperation wants.
|
||||
|
||||
SHFILEOPSTRUCTW op{};
|
||||
op.hwnd = nullptr;
|
||||
op.wFunc = FO_DELETE;
|
||||
op.pFrom = wbuf.data();
|
||||
op.pTo = nullptr;
|
||||
op.fFlags = static_cast<FILEOP_FLAGS>(FOF_ALLOWUNDO | FOF_NOCONFIRMATION |
|
||||
FOF_SILENT | FOF_NOERRORUI);
|
||||
const int rv = SHFileOperationW(&op);
|
||||
if (rv == 0 && !op.fAnyOperationsAborted) {
|
||||
outUsedTrash = true;
|
||||
return true; // deleted this call -> reclaimed
|
||||
}
|
||||
// SHFileOperation failed (e.g. file already gone yields a nonzero code on some
|
||||
// versions, or a lock). Distinguish "already absent" from a real failure so the
|
||||
// caller can tally them separately (absent -> staleness skip; failure -> locked skip).
|
||||
std::error_code ec;
|
||||
if (!fs::exists(absPath, ec)) {
|
||||
outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim
|
||||
}
|
||||
return false;
|
||||
#else
|
||||
// No portable trash surface on SWELL platforms -> hard unlink behind the confirm.
|
||||
std::error_code ec;
|
||||
const bool removed = fs::remove(absPath, ec);
|
||||
if (removed) return true; // deleted this call -> reclaimed
|
||||
if (ec) return false; // a real failure (locked / permission) -> skip
|
||||
// remove returned false with no error == the file did not exist -> already gone.
|
||||
outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
reclaim::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.
|
||||
reclaim::PruneReport report =
|
||||
reclaim::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).
|
||||
// The offending key names propagate so the action can name each one for recovery.
|
||||
report.abortedUnreadableUsage = scan.abortedUnreadableUsage;
|
||||
report.offendingUsageKeys = scan.offendingUsageKeys;
|
||||
return report;
|
||||
}
|
||||
|
||||
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
|
||||
return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated
|
||||
}
|
||||
|
||||
reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim(
|
||||
const std::vector<std::string>& confirmed) const {
|
||||
reclaim::PruneDeletionResult result;
|
||||
|
||||
// Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets
|
||||
// exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became
|
||||
// referenced between confirm and delete drops out of freshOrphans and is skipped; a
|
||||
// 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
|
||||
|
||||
const std::vector<std::string> plan =
|
||||
reclaim::pruneDeletePlan(confirmed, scan.orphans);
|
||||
|
||||
// Staleness skip count: entries the user confirmed that are no longer fresh orphans
|
||||
// (vanished or became referenced between confirm and delete). pruneDeletePlan already
|
||||
// de-dups confirmed internally, so compute the unique-confirmed size to avoid counting
|
||||
// de-duplicated entries as stale — that would be dishonest.
|
||||
const std::size_t uniqueConfirmedCount =
|
||||
std::unordered_set<std::string>(confirmed.begin(), confirmed.end()).size();
|
||||
result.skippedCount += uniqueConfirmedCount - plan.size();
|
||||
|
||||
for (const std::string& rel : plan) {
|
||||
// Reconstruct the absolute path from the resolved bank dir + the entry's file name.
|
||||
// rel is index-spelled "<kBankSubfolder>/<name>"; the name is the tail after '/'.
|
||||
const std::string::size_type slash = rel.find_last_of('/');
|
||||
const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1);
|
||||
if (name.empty()) { ++result.skippedCount; continue; }
|
||||
const std::string absPath = scan.bankDirAbs + "/" + name;
|
||||
|
||||
const auto szIt = scan.sizeByRel.find(rel);
|
||||
const std::uint64_t bytes = (szIt != scan.sizeByRel.end()) ? szIt->second : 0;
|
||||
|
||||
bool alreadyAbsent = false;
|
||||
if (deleteOrphanFile(absPath, result.usedTrash, alreadyAbsent)) {
|
||||
++result.reclaimedCount;
|
||||
result.reclaimedBytes += bytes;
|
||||
} else if (alreadyAbsent) {
|
||||
// File vanished between plan and delete — treat as staleness, same as the
|
||||
// confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it).
|
||||
++result.skippedCount;
|
||||
} else {
|
||||
++result.skippedCount; // locked / conversion failure -> recorded, not thrown
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
Reference in New Issue
Block a user