Prove the bake's answer writes by reading the key back

SetProjExtState's return covers the whole extname, so it never saw one key. The persist verdict now reaches the report, and a throw mid-write no longer claims the landing left nothing behind.
This commit is contained in:
2026-08-02 12:00:34 -04:00
parent 6cabbd0dd4
commit 1800bd64c3
8 changed files with 491 additions and 178 deletions
+221 -120
View File
@@ -81,61 +81,82 @@ const Sample* findSourceSample(const BankBook& book, const std::string& sampleId
return nullptr;
}
// Lands ONE request into `session`'s book, which must already be the book of the project
// `projectDir` names. Mutates book + ledger without persisting; the caller persists once for
// the batch. Every refusal path leaves the book untouched and writes no file, so a failed
// bake is invisible to the project.
BakeOutcome landOne(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
if (projectDir.empty())
return refuse(BakeStatus::NoProject,
"no saved project, so the bank has no location", request.generation);
// Everything a landing DECIDES, before it touches disk or book: the staged WAV read and
// hash, the source lookup, replace-vs-add, and the dedup short-circuit. Split from the
// commit below so the two halves' throws can be told apart — a prepare that threw left the
// project untouched, and only a commit that threw may have written something.
struct PreparedLanding {
std::optional<BakeOutcome> settled; // a refusal or a dedup hit: nothing left to commit
BakeOutcome outcome; // the shape a successful commit completes
Sample landed; // the entry the commit indexes
std::vector<std::uint8_t> bytes; // the staged file, already read
std::string bankId;
std::string destDir;
std::string destPath;
bool replace = false;
};
const std::vector<std::uint8_t> bytes = util::readFileBytes(request.stagedFilePath);
if (bytes.empty())
return refuse(BakeStatus::StagedMissing, "the staged render was unreadable",
request.generation);
const WavLayout layout = parseWavLayout(bytes);
if (!layout.valid || layout.frameCount() == 0)
return refuse(BakeStatus::StagedMissing, "the staged render is not a usable WAV",
request.generation);
PreparedLanding prepareLanding(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
PreparedLanding prep;
if (projectDir.empty()) {
prep.settled = refuse(BakeStatus::NoProject,
"no saved project, so the bank has no location",
request.generation);
return prep;
}
prep.bytes = util::readFileBytes(request.stagedFilePath);
if (prep.bytes.empty()) {
prep.settled = refuse(BakeStatus::StagedMissing, "the staged render was unreadable",
request.generation);
return prep;
}
const WavLayout layout = parseWavLayout(prep.bytes);
if (!layout.valid || layout.frameCount() == 0) {
prep.settled = refuse(BakeStatus::StagedMissing,
"the staged render is not a usable WAV", request.generation);
return prep;
}
BankBook& book = session.book();
std::string bankId;
const Sample* source = findSourceSample(book, request.sourceSampleId, bankId);
if (!source)
return refuse(BakeStatus::NoSource, "the resampled capture is not in any bank",
request.generation);
// Copied, not aliased: every mutation below invalidates the book's pointers.
const Sample* source = findSourceSample(book, request.sourceSampleId, prep.bankId);
if (!source) {
prep.settled = refuse(BakeStatus::NoSource,
"the resampled capture is not in any bank", request.generation);
return prep;
}
// Copied, not aliased: every mutation the commit makes invalidates the book's pointers.
const Sample sourceCopy = *source;
const tracking::Landing landing = tracking::resampleLanding(
session.tiedUsageFor(request.sourceRelativePath, request.ownUsageKey));
const bool replace = (landing == tracking::Landing::Replace);
prep.replace = (landing == tracking::Landing::Replace);
// WAV-aware hash: the bank's dedup key, and — on the add path only — the reason a
// byte-identical bake yields no second entry. Replace never dedups, matching
// updateSampleInPlace's own contract: an in-place refresh is not an insert.
const std::string contentHash = hashWavContent(bytes);
const std::string contentHash = hashWavContent(prep.bytes);
BakeOutcome out;
out.generation = request.generation;
out.rootNote = request.rootNote;
out.channelCount = static_cast<int>(layout.channelCount);
out.replaced = replace;
prep.outcome.generation = request.generation;
prep.outcome.rootNote = request.rootNote;
prep.outcome.channelCount = static_cast<int>(layout.channelCount);
prep.outcome.replaced = prep.replace;
if (!replace && !contentHash.empty()) {
if (const model::BankModel* index = book.index(bankId)) {
if (!prep.replace && !contentHash.empty()) {
if (const model::BankModel* index = book.index(prep.bankId)) {
if (const Sample* existing = index->findByHash(contentHash)) {
// Dedup BEFORE the disk write: this bake is bytes the bank already holds,
// so it re-points at that entry rather than depositing an unreferenced
// twin for the prune to reclaim later.
BakeOutcome out = prep.outcome;
out.status = BakeStatus::Ok;
out.sampleId = existing->id;
out.relativePath = existing->relativePath;
out.displayName = existing->displayName;
out.message = "identical to an existing capture -- pointed at it";
return out;
prep.settled = std::move(out);
return prep;
}
}
}
@@ -151,61 +172,128 @@ BakeOutcome landOne(ReaSamplerSession& session, const std::string& projectDir,
const std::string stem =
sourceCopy.displayName.empty() ? std::string("resample") : sourceCopy.displayName;
const BankPaths paths = deriveBankPaths(projectDir, stem, uniqueTag);
prep.destDir = paths.absoluteDir;
prep.destPath = paths.absoluteDir + "/" + paths.fileName;
std::error_code ec;
fs::create_directories(paths.absoluteDir, ec); // idempotent; the write reports failure
const std::string destPath = paths.absoluteDir + "/" + paths.fileName;
if (!writeFileBytes(destPath, bytes))
return refuse(BakeStatus::Failed, "could not write the bake into the bank folder",
request.generation);
Sample landed;
// Replace keeps the entry's identity and its slot — the sound iterated, it did not
// become a different capture. The superseded FILE is untouched: it stays on disk,
// unreferenced, until a prune reclaims it, which is the iterate loop's recovery floor.
landed.id = replace ? sourceCopy.id
: ("bake-" + uniqueTag + "-" + paths.fileName);
landed.displayName =
replace ? sourceCopy.displayName : nextIterationName(sourceCopy.displayName);
landed.relativePath = paths.relativePath; // project-relative (invariant)
landed.channelCount = static_cast<int>(layout.channelCount);
landed.sampleRate = static_cast<int>(layout.sampleRate);
landed.lengthSeconds =
prep.landed.id =
prep.replace ? sourceCopy.id : ("bake-" + uniqueTag + "-" + paths.fileName);
prep.landed.displayName =
prep.replace ? sourceCopy.displayName : nextIterationName(sourceCopy.displayName);
prep.landed.relativePath = paths.relativePath; // project-relative (invariant)
prep.landed.channelCount = static_cast<int>(layout.channelCount);
prep.landed.sampleRate = static_cast<int>(layout.sampleRate);
prep.landed.lengthSeconds =
layout.sampleRate ? static_cast<double>(layout.frameCount()) /
static_cast<double>(layout.sampleRate)
: 0.0;
landed.rootNote = request.rootNote; // rendered AT root — that is what makes it survive
landed.tier = model::Tier::Scratch;
landed.contentHash = contentHash;
landed.createdTimestamp = nowSec;
// Rendered AT root — that is what makes it survive.
prep.landed.rootNote = request.rootNote;
prep.landed.tier = model::Tier::Scratch;
prep.landed.contentHash = contentHash;
prep.landed.createdTimestamp = nowSec;
// The lineage seed recordCreated reads: the ledger's parent chain is what makes a
// repeated bake readable as one iteration chain.
landed.provenance = model::Provenance{sourceCopy.id, std::string{}};
prep.landed.provenance = model::Provenance{sourceCopy.id, std::string{}};
return prep;
}
const bool indexed = replace
? book.updateSampleInPlace(sourceCopy.id, landed)
: (book.index(bankId) &&
book.index(bankId)->add(landed) == AddResult::Added);
// The mutating half. From its first successful write a file exists and the book can change,
// so a throw out of THIS function is not invisible to the project — which is why its caller
// reports it differently from a prepare that threw. Its own refusals withdraw the file they
// wrote, best-effort. Mutates book + ledger without persisting; the caller persists once for
// the batch.
BakeOutcome commitLanding(ReaSamplerSession& session, PreparedLanding& prep) {
std::error_code ec;
fs::create_directories(prep.destDir, ec); // idempotent; the write reports failure
if (!writeFileBytes(prep.destPath, prep.bytes)) {
// A failed write can still have created the file before failing; withdraw it for
// the same reason the refused index below does.
fs::remove(prep.destPath, ec);
return refuse(BakeStatus::Failed, "could not write the bake into the bank folder",
prep.outcome.generation);
}
BankBook& book = session.book();
// On the replace path prepareLanding set `landed.id` to the source's own id, so this is
// the entry being refreshed in place.
const bool indexed =
prep.replace ? book.updateSampleInPlace(prep.landed.id, prep.landed)
: (book.index(prep.bankId) &&
book.index(prep.bankId)->add(prep.landed) == AddResult::Added);
if (!indexed) {
// Self-cleanup of a file this call wrote seconds ago and never indexed — the
// carve-out shell/persist/CLAUDE.md states, not prune's authority over the bank's
// known bytes. Leaving it would deposit an untracked orphan per refused bake.
fs::remove(destPath, ec);
fs::remove(prep.destPath, ec);
return refuse(BakeStatus::IndexRejected,
replace ? "the bank refused the replacement"
: "the bank refused the new capture",
request.generation);
prep.replace ? "the bank refused the replacement"
: "the bank refused the new capture",
prep.outcome.generation);
}
session.recordCreated(landed, tracking::OriginKind::Capture);
session.recordCreated(prep.landed, tracking::OriginKind::Capture);
BakeOutcome out = prep.outcome;
out.status = BakeStatus::Ok;
out.sampleId = landed.id;
out.relativePath = landed.relativePath;
out.displayName = landed.displayName;
out.message = replace ? "replaced the bank entry" : "added as a distinct capture";
out.sampleId = prep.landed.id;
out.relativePath = prep.landed.relativePath;
out.displayName = prep.landed.displayName;
out.message = prep.replace ? "replaced the bank entry" : "added as a distinct capture";
return out;
}
// One request landed, with the two failure truths kept apart: a throw out of the prepare
// half is a refusal that left the project untouched, while a throw out of the commit half
// may have written a file and changed the book — calling that one "refused" would tell the
// user nothing happened when something did.
struct LandingAttempt {
BakeOutcome outcome;
bool partial = false; // the commit threw: a file and/or an entry may exist
bool changedBook = false; // the book gained or refreshed an entry, unpersisted so far
};
LandingAttempt attemptLanding(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
LandingAttempt attempt;
PreparedLanding prep;
try {
prep = prepareLanding(session, projectDir, request);
} catch (const std::exception& e) {
attempt.outcome =
refuse(BakeStatus::Failed,
std::string("the landing failed before it wrote anything: ") + e.what(),
request.generation);
return attempt;
} catch (...) {
attempt.outcome =
refuse(BakeStatus::Failed,
"the landing failed before it wrote anything, for an unknown reason",
request.generation);
return attempt;
}
if (prep.settled) {
attempt.outcome = std::move(*prep.settled);
return attempt; // a refusal, or a dedup hit that changed nothing
}
try {
attempt.outcome = commitLanding(session, prep);
attempt.changedBook = attempt.outcome.status == BakeStatus::Ok;
} catch (const std::exception& e) {
attempt.partial = true;
attempt.outcome = refuse(BakeStatus::Failed,
std::string("the landing failed while writing: ") + e.what(),
request.generation);
} catch (...) {
attempt.partial = true;
attempt.outcome = refuse(BakeStatus::Failed,
"the landing failed while writing, for an unknown reason",
request.generation);
}
return attempt;
}
// Every "rsbake_*" key in one project, keys only — the value can outgrow
// EnumProjExtState's fixed buffer, so it is read separately by the growing reader.
std::vector<std::string> pendingBakeKeys(ReaProject* proj) {
@@ -241,6 +329,15 @@ KeyRead readKey(ReaProject* proj, const std::string& key) {
return out;
}
// Writes one value under `key` and PROVES it by reading the key back. SetProjExtState's own
// return cannot do that: it is the size of the whole extname's state, which `banks` alone
// keeps non-zero in every case a bake can reach. One extra read per written key, on a cold
// path. An empty `value` is a clear.
bool writeKeyVerified(ReaProject* proj, const std::string& key, const std::string& value) {
SetProjExtState(proj, kProjExtNamespace(), key.c_str(), value.c_str());
return wire::bakeWriteLanded(value, readKey(proj, key).value);
}
struct OpenProject {
ReaProject* proj = nullptr;
std::string dir; // the project's own directory; empty for a never-saved project
@@ -260,18 +357,20 @@ std::vector<OpenProject> openProjects() {
return out;
}
// One scanned key: what the pass decided about it, and the answer to write back after the
// undo block closes.
// One scanned key: what the pass decided about it, and what to write back after the undo
// block closes. The outcome is kept UNENCODED until then, because the pass's persist runs
// after the whole scan and an Ok the project never took must not be sent as one.
struct ScannedKey {
enum class Write { None, Clear, Answer };
ReaProject* proj = nullptr;
std::string key;
bool writesAnswer = false; // false = an Ignore verdict, nothing to write
std::string answerWire; // with writesAnswer: empty = clear the key instead
std::string console; // the outcome's own sentence; empty = nothing to print
Write write = Write::None; // None = an Ignore verdict, nothing to write
BakeOutcome outcome; // Write::Answer only
wire::BakeKeyOutcome report;
};
// Undo_BeginBlock2/EndBlock2 as stack RAII, the FxBypassGuard discipline: landOne reads a
// Undo_BeginBlock2/EndBlock2 as stack RAII, the FxBypassGuard discipline: a landing reads a
// whole WAV and hashes it, so a bad_alloc between the two calls would otherwise leave an
// open undo block in the user's project. Closing with an empty description records no undo
// point, which is what every path that landed nothing wants anyway.
@@ -313,9 +412,10 @@ void RunResampleBake(ReaSamplerSession& session) {
std::vector<ScannedKey> scanned;
wire::BakeScanTally tally; // every verdict below is counted, skips included
std::string aborted; // set only when the scan itself threw
// The scan allocates outside landOne's own guard too (readKey grows a buffer toward
// 16 MB, every container here allocates), and a throw that escaped would discard the
// answers already buffered — the exact no-answer-with-a-silent-console this action
bool persisted = false; // the pass's ONE persist actually happened
// The scan allocates outside attemptLanding's own guards too (readKey grows a buffer
// toward 16 MB, every container here allocates), and a throw that escaped would discard
// the answers already buffered — the exact no-answer-with-a-silent-console this action
// exists to make impossible. UndoBlock is inside the try, so unwinding still closes it.
try {
UndoBlock undo;
@@ -358,49 +458,36 @@ void RunResampleBake(ReaSamplerSession& session) {
}
if (verdict == wire::BakeScanVerdict::ClearStale) {
++tally.staleCleared;
entry.writesAnswer = true; // empty answerWire = clear the key
entry.write = ScannedKey::Write::Clear;
scanned.push_back(std::move(entry));
continue;
}
BakeOutcome outcome;
if (verdict == wire::BakeScanVerdict::RefuseWrongProject) {
outcome =
entry.outcome =
refuse(BakeStatus::WrongProject,
"this bake's project tab is not the one the extension has "
"loaded -- focus that tab and try again",
request->generation);
} else {
// landOne reads and hashes a whole WAV. A throw here would otherwise
// discard every answer buffered so far and leave each asking instance
// with a no-answer it cannot explain; converting it to a refusal keeps
// the pass and its report intact.
try {
outcome = landOne(session, open.dir, *request);
} catch (const std::exception& e) {
outcome = refuse(BakeStatus::Failed,
std::string("the landing failed: ") + e.what(),
request->generation);
} catch (...) {
outcome = refuse(BakeStatus::Failed,
"the landing failed for an unknown reason",
request->generation);
}
if (outcome.status == BakeStatus::Ok) ++tally.landed;
LandingAttempt attempt = attemptLanding(session, open.dir, *request);
if (attempt.outcome.status == BakeStatus::Ok) ++tally.landed;
// Unpersisted rather than Banked for anything that changed the book: the
// pass's persist has not run yet, and the write loop is where the
// upgrade is earned. A dedup hit changed nothing, so its landing is not
// this pass's to lose.
if (attempt.partial)
entry.report.landing = wire::BakeLanding::Partial;
else if (attempt.outcome.status != BakeStatus::Ok)
entry.report.landing = wire::BakeLanding::Refused;
else
entry.report.landing = attempt.changedBook
? wire::BakeLanding::Unpersisted
: wire::BakeLanding::Banked;
entry.outcome = std::move(attempt.outcome);
}
++tally.answered; // QUEUED, not written — the write loop below judges that
entry.report.landed = outcome.status == BakeStatus::Ok;
// A WrongProject request left sitting in a tab this call did not come from
// prints again on every OTHER tab's bake, since the scan revisits every
// open project each time. Only a refusal in REAPER's active tab is likely
// to be fresh feedback to a user who just clicked bake; every other one is
// a rescan repeat. The per-key line below is printed either way.
if (outcome.status != BakeStatus::Ok &&
(outcome.status != BakeStatus::WrongProject || activeTab)) {
entry.console = "ReaSampler resample: " + outcome.message + ".\n";
}
entry.writesAnswer = true;
entry.answerWire = wire::encodeBakeOutcome(outcome);
entry.write = ScannedKey::Write::Answer;
scanned.push_back(std::move(entry));
}
}
@@ -409,14 +496,15 @@ void RunResampleBake(ReaSamplerSession& session) {
// A bake changes what a live instance would play, so the generation bump rides
// the persist — every other open instance refreshes hands-free.
session.bumpBankGeneration();
if (session.saveToActiveProject())
persisted = session.saveToActiveProject();
if (persisted)
undo.recordPoint("ReaSampler: resample bake into bank", UNDO_STATE_MISCCFG);
}
} catch (...) {
aborted =
"ReaSampler resample: the landing pass stopped early on an internal failure. "
"The answers it had already prepared are written below; any key it had not "
"reached yet went unanswered and is not named at all.\n";
"The answers it had already prepared are written below; any key whose answer it "
"had not prepared yet went unanswered and is not named at all.\n";
}
// OUTSIDE the undo block on purpose: the answer is transient handshake state, and an
@@ -424,20 +512,33 @@ void RunResampleBake(ReaSamplerSession& session) {
// which the following bake would then re-land against a temp file that is long gone.
std::string console = aborted;
for (ScannedKey& entry : scanned) {
if (entry.writesAnswer) {
// SetProjExtState returns the size of the extname's state, so storing a
// non-empty value necessarily returns > 0 and <= 0 means the write did not
// land. A deliberate clear shrinks the state and can legitimately return 0 —
// the same reading reaper_bridge's writeGuarded applies to the other end.
const int rv = SetProjExtState(entry.proj, kProjExtNamespace(),
entry.key.c_str(), entry.answerWire.c_str());
entry.report.answerWritten = entry.answerWire.empty() || rv > 0;
if (!entry.report.answerWritten) ++tally.writeFailed;
if (entry.write != ScannedKey::Write::None) {
std::string value; // empty = clear the key
if (entry.write == ScannedKey::Write::Answer) {
// Where a landing earns the word: the persist ran after the whole scan, so
// until here nothing had observed whether the project took it. Without it
// the entry lives in memory alone, which is a failed bake to anyone who
// reloads — so it is answered as one rather than as an Ok.
if (entry.report.landing == wire::BakeLanding::Unpersisted) {
if (persisted)
entry.report.landing = wire::BakeLanding::Banked;
else
entry.outcome = refuse(BakeStatus::Failed,
"the bake reached the bank in memory, but "
"this pass could not persist it, so the "
"project's saved bank state does not carry it",
entry.outcome.generation);
}
value = wire::encodeBakeOutcome(entry.outcome);
entry.report.detail = entry.outcome.message;
}
entry.report.writeConfirmed = writeKeyVerified(entry.proj, entry.key, value);
if (!entry.report.writeConfirmed && entry.write == ScannedKey::Write::Answer)
++tally.writeFailed;
}
// Unconditional, including on success: the tally counts keys but cannot name them,
// and the key name is the only thing that tells an instance which line is its own.
console += wire::describeBakeKey(entry.key, entry.report);
console += entry.console;
}
console += wire::describeBakeScan(tally);
if (!console.empty()) ShowConsoleMsg(console.c_str());