Files
reasampler/src/shell/persist/prune_fs.cpp
T

289 lines
14 KiB
C++

// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler.
//
// 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 (a shell removing a
// transient scratch file it just created, e.g. the drop path's temp
// .vstpreset, is self-cleanup, not authority over user data). Deliberately
// file-local (anonymous namespace): nothing outside this TU can reach it, and
// this concentration must never spread. The safety-critical "which files are
// orphans" decision stays in the pure core (prune_reconcile); this TU only
// enumerates, resolves, stats, and — after the confirm — executes.
//
// Compiled into the reaper_reasampler module. REAPER-facing only through the
// persist_detail helpers and usage_scan; 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, trash-preferred. Windows reaches the Recycle Bin via
// SHFileOperationW + FOF_ALLOWUNDO (verified against shellapi.h: SHFILEOPSTRUCTW
// { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... }, FO_DELETE=0x3,
// FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on SWELL (macOS/Linux),
// so those platforms fall back to unlink — see deleteOrphanFile below.
#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" // 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/tracking/tracking_authority.h" // the one protection answer
namespace reasampler {
namespace {
namespace fs = std::filesystem;
using persist_detail::projectDirOf;
using persist_detail::readActiveProject;
// The dry-run file-list display cap: count and size are always exact
// (tallied over the full orphan set), but the enumerated list handed to the
// console is clipped so a project with thousands of orphans does not flood
// the report. PruneReport::truncated flags the clip.
constexpr std::size_t kPruneListDisplayCap = 64;
// A fresh enumerate + pure-core prune compute for the active project. Shared
// by the dry-run report, the full-set query, and the deletion so all three
// agree on one resolution + enumeration + set-algebra path — no divergence
// between what is shown and what is deleted. REAPER-facing but writes nothing.
//
// * bankDirAbs — the resolved current bank folder. Empty when there is no
// 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).
// * 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 blocked = false;
bool ledgerUnreadable = false;
bool ledgerFutureVersion = 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 tracking::OriginLedger& ledger,
tracking::LedgerStatus ledgerStatus) {
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 — never a
// stored absolute path, so a Save-As relocation is followed automatically.
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 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 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;
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; 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 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.ledgerFutureVersion = protection.ledgerFutureVersion;
scan.unreadableUsageKeys = protection.unreadableUsageKeys;
return scan;
}
scan.orphans = reclaim::pruneOrphans(
present,
reclaim::mergeReferenced(book.referencedPaths(), protection.heldPaths),
protection.ownedPaths);
return scan;
}
// Deletes ONE orphan file, trash-preferred. Returns true iff deleted by this
// call. Returns false with `outAlreadyAbsent` set when the file was already
// gone (caller folds into staleness, not reclaimedCount); false with it unset
// on a real delete failure (locked, conversion error — folds into
// skippedCount). `absPath` is the resolved absolute path. Non-throwing: no
// exception may cross the C ABI.
//
// Windows routes through SHFileOperationW + FOF_ALLOWUNDO (Recycle Bin,
// recoverable); the no-UI flags suppress REAPER-blocking dialogs since our own
// confirm already happened. Other platforms (SWELL: macOS/Linux) have no
// portable move-to-trash surface, so they fall back to std::filesystem::remove
// (hard unlink) behind the confirm guardrail.
bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
bool& outAlreadyAbsent) {
#ifdef _WIN32
// Back-slashed, double-NUL-terminated wide string: SHFileOperation's
// pFrom is a list (needs the extra terminating NUL) and rejects 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
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);
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
}
// Distinguish "already absent" (nonzero return on some Windows/shell versions
// for a vanished file) from a real failure so the caller can tally separately.
std::error_code ec;
if (!fs::exists(absPath, ec)) {
outAlreadyAbsent = true;
}
return false;
#else
// No portable trash surface on SWELL platforms -> hard unlink.
std::error_code ec;
const bool removed = fs::remove(absPath, ec);
if (removed) return true;
if (ec) return false; // real failure (locked/permission) -> skip
outAlreadyAbsent = true; // no error, no removal -> already gone
return false;
#endif
}
} // namespace
tracking::Answer ReaSamplerSession::tiedUsageFor(const std::string& capturePath,
const std::string& ownUsageKey) const {
// The SECOND consumer of the same gather the prune scan above runs — deliberately
// next to it, so "both answers come out of one TrackingState" is structural rather
// than a rule two files have to remember.
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj) return tracking::Answer::Indeterminate;
const wire::UsageFoldResult usage = scanInstanceUsage(proj);
const tracking::TrackingState state{trackingStatus_, tracking_, usage};
return tracking::tiedUsageExists(state, capturePath, ownUsageKey);
}
reclaim::PruneReport ReaSamplerSession::pruneDryRun() const {
const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_);
reclaim::PruneReport report =
reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
// 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.ledgerFutureVersion = scan.ledgerFutureVersion;
report.unreadableUsageKeys = scan.unreadableUsageKeys;
return report;
}
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
// 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(
const std::vector<std::string>& confirmed) const {
reclaim::PruneDeletionResult result;
// Re-enumerate + run the pure core FRESH (never a stale set): deletion
// 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 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_, tracking_, trackingStatus_);
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: confirmed entries no longer fresh orphans.
// pruneDeletePlan de-dups confirmed internally, so compare against the
// unique-confirmed size to avoid counting de-duped entries as stale.
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) {
// rel is index-spelled "<kBankSubfolder>/<name>"; reconstruct the
// absolute path from the resolved bank dir + 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) {
++result.skippedCount; // vanished between plan and delete -> staleness
} else {
++result.skippedCount; // locked / conversion failure -> recorded, not thrown
}
}
return result;
}
} // namespace reasampler