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
+168 -14
View File
@@ -7,7 +7,8 @@
// break a delimiter-based format; the refusals every house wire record shares (wrong tag,
// truncation, trailing garbage, a swapped record kind); an unrecognized status integer
// degrading to Failed rather than to Ok; the action lookup name's leading underscore and
// its channel fork; and the two key classifiers each end reads the shared key through.
// its channel fork; the two key classifiers each end reads the shared key through; and the
// write-back verdict, driven by a modelled key store that accepts or drops the write.
#include "../src/core/wire/bake_wire.h"
@@ -15,6 +16,7 @@
#include <cstdint>
#include <cstdio>
#include <map>
#include <optional>
#include <string>
@@ -482,7 +484,9 @@ int main() {
CHECK(other.find("1 held something other than a request") != std::string::npos);
CHECK(other.find("could not be read back") == std::string::npos);
// Cleared as stale: an answer was never written, so this too must report.
// Cleared as stale: an answer was never written, so this too must report. The
// summary may NOT say the clears were written — whether each one took is a per-key
// read-back, and only describeBakeKey has seen it.
BakeScanTally stale;
stale.tabsScanned = 1;
stale.keysFound = 1;
@@ -490,6 +494,7 @@ int main() {
stale.staleCleared = 1;
const std::string aged = describeBakeScan(stale);
CHECK(aged.find("past the age bound") != std::string::npos);
CHECK(aged.find("were cleared") == std::string::npos);
// activeTabKeys is REAPER's ACTIVE tab and nothing more. It cannot discriminate the
// multi-tab mis-target — a genuine background-tab request classifies as
@@ -515,40 +520,77 @@ int main() {
BakeKeyOutcome landed;
landed.verdict = BakeScanVerdict::Land;
landed.landed = true;
landed.answerWritten = true;
landed.landing = BakeLanding::Banked;
landed.writeConfirmed = true;
landed.detail = "added as a distinct capture";
const std::string ok = describeBakeKey(key, landed);
CHECK(ok.find(key) != std::string::npos);
CHECK(ok.find("landed into the bank") != std::string::npos);
CHECK(ok.find("the answer was written back") != std::string::npos);
CHECK(ok.find("The answer was written back") != std::string::npos);
CHECK(ok.back() == '\n');
// The outcome's own reason rides the SAME line: one self-contained line per key,
// rather than a keyed verdict and an unkeyed reason the reader has to pair up.
CHECK(ok.find("(added as a distinct capture)") != std::string::npos);
// A landing whose answer write was REJECTED — the state the instrument reads as
// Unanswered. This line is the only place it is ever named.
BakeKeyOutcome lost = landed;
lost.answerWritten = false;
lost.writeConfirmed = false;
const std::string dropped = describeBakeKey(key, lost);
CHECK(dropped.find("landed into the bank") != std::string::npos);
CHECK(dropped.find("could NOT be written back") != std::string::npos);
CHECK(dropped.find("will report no answer") != std::string::npos);
// The four landing states are four different lines: a bake the project never
// persisted, and one that threw mid-write, may not read as a clean success or as a
// clean refusal.
BakeKeyOutcome unpersisted = landed;
unpersisted.landing = BakeLanding::Unpersisted;
const std::string memoryOnly = describeBakeKey(key, unpersisted);
CHECK(memoryOnly.find("IN MEMORY ONLY") != std::string::npos);
CHECK(memoryOnly.find("could not persist it") != std::string::npos);
CHECK(memoryOnly != ok);
BakeKeyOutcome partial = landed;
partial.landing = BakeLanding::Partial;
partial.detail = "the landing failed while writing: bad allocation";
const std::string half = describeBakeKey(key, partial);
CHECK(half.find("after it had begun writing") != std::string::npos);
CHECK(half.find("may have left a file") != std::string::npos);
CHECK(half.find("the landing was refused") == std::string::npos);
BakeKeyOutcome bankRefused;
bankRefused.verdict = BakeScanVerdict::Land;
bankRefused.landed = false;
bankRefused.answerWritten = true;
CHECK(describeBakeKey(key, bankRefused).find("the landing was refused") !=
std::string::npos);
bankRefused.landing = BakeLanding::Refused;
bankRefused.writeConfirmed = true;
bankRefused.detail = "the bank refused the new capture";
const std::string refusedLine = describeBakeKey(key, bankRefused);
CHECK(refusedLine.find("the landing was refused") != std::string::npos);
// The refusal's REASON is what the keyed line used to lack entirely.
CHECK(refusedLine.find("(the bank refused the new capture)") != std::string::npos);
BakeKeyOutcome wrongTab;
wrongTab.verdict = BakeScanVerdict::RefuseWrongProject;
wrongTab.answerWritten = true;
wrongTab.writeConfirmed = true;
wrongTab.detail =
"this bake's project tab is not the one the extension has loaded -- focus that "
"tab and try again";
const std::string refused = describeBakeKey(key, wrongTab);
CHECK(refused.find("not the one this extension has loaded") != std::string::npos);
CHECK(refused.find("the answer was written back") != std::string::npos);
CHECK(refused.find("not the one the extension has loaded") != std::string::npos);
CHECK(refused.find("focus that tab and try again") != std::string::npos);
CHECK(refused.find("The answer was written back") != std::string::npos);
// A clear is a write too: it may be claimed only where it was read back.
BakeKeyOutcome cleared;
cleared.verdict = BakeScanVerdict::ClearStale;
cleared.writeConfirmed = true;
CHECK(describeBakeKey(key, cleared).find("cleared unanswered") != std::string::npos);
BakeKeyOutcome clearLost = cleared;
clearLost.writeConfirmed = false;
const std::string stuck = describeBakeKey(key, clearLost);
CHECK(stuck.find("the clear could NOT be read back") != std::string::npos);
CHECK(stuck.find("next pass will see it again") != std::string::npos);
CHECK(stuck.find("it was cleared") == std::string::npos);
BakeKeyOutcome notRequest;
notRequest.verdict = BakeScanVerdict::IgnoreNotARequest;
@@ -573,6 +615,105 @@ int main() {
CHECK(describeBakeKey("rsbake_ffff0000", landed) != ok);
}
// --- A rejected write is REACHABLE, and it is what the tally and the line come from ---
// The shell used to judge this by SetProjExtState's return, which is the size of the
// WHOLE extname's state — `banks` alone keeps that non-zero in every case a bake can
// reach, so `writeFailed` could not be produced at all and the sentence for it was dead
// code. The verdict is now the read-back below, which a store that drops the write does
// produce. The store is modelled here; the shell binds these same two calls to
// SetProjExtState and its existing grow-loop GetProjExtState read.
{
struct FakeKeyStore {
std::map<std::string, std::string> values;
bool dropWrites = false;
void write(const std::string& k, const std::string& v) {
if (dropWrites) return; // REAPER rejected it
if (v.empty()) values.erase(k); // SetProjExtState("") deletes
else values[k] = v;
}
std::optional<std::string> read(const std::string& k) const {
const auto it = values.find(k);
return it == values.end() ? std::nullopt
: std::optional<std::string>(it->second);
}
};
const std::string key = "rsbake_0123abcd";
BakeRequest pending;
pending.instanceGuid = "0123abcd";
pending.generation = 1893456000;
BakeOutcome landedOk;
landedOk.status = BakeStatus::Ok;
landedOk.sampleId = "bake-1";
landedOk.message = "added as a distinct capture";
landedOk.generation = pending.generation;
const std::string answer = encodeBakeOutcome(landedOk);
// Rejected: the key still holds the request the instrument left there.
FakeKeyStore rejecting;
rejecting.values[key] = encodeBakeRequest(pending);
rejecting.dropWrites = true;
rejecting.write(key, answer);
CHECK(!bakeWriteLanded(answer, rejecting.read(key)));
// ...which is exactly what the ASKING end reads as a no-answer. The two classifiers
// agree about this one state, which is why the extension has to name it.
CHECK(classifyBakeAnswer(rejecting.read(key), pending).kind ==
BakeAnswerKind::Unanswered);
// The shell's write loop from that verdict through to both strings it prints.
BakeScanTally tally;
tally.tabsScanned = 1;
tally.keysFound = 1;
tally.activeTabKeys = 1;
tally.answered = 1;
tally.landed = 1;
BakeKeyOutcome report;
report.verdict = BakeScanVerdict::Land;
report.landing = BakeLanding::Banked;
report.detail = landedOk.message;
report.writeConfirmed = bakeWriteLanded(answer, rejecting.read(key));
if (!report.writeConfirmed) ++tally.writeFailed;
CHECK(tally.writeFailed == 1);
CHECK(describeBakeKey(key, report).find("could NOT be written back") !=
std::string::npos);
CHECK(describeBakeScan(tally).find("1 answer could not be written back") !=
std::string::npos);
// The same store ACCEPTING the write: the verdict flips, the tally stays at zero and
// the summary goes silent — so neither answer above is a constant.
FakeKeyStore accepting;
accepting.values[key] = encodeBakeRequest(pending);
accepting.write(key, answer);
CHECK(bakeWriteLanded(answer, accepting.read(key)));
CHECK(classifyBakeAnswer(accepting.read(key), pending).kind ==
BakeAnswerKind::Answered);
BakeScanTally clean = tally;
clean.writeFailed = 0;
CHECK(describeBakeScan(clean).empty());
// A CLEAR is proven the other way round: it lands as an ABSENT key, and a dropped
// clear leaves the stale request standing for the next pass to find.
FakeKeyStore clearing;
clearing.values[key] = encodeBakeRequest(pending);
clearing.write(key, "");
CHECK(bakeWriteLanded("", clearing.read(key)));
FakeKeyStore clearDropped;
clearDropped.values[key] = encodeBakeRequest(pending);
clearDropped.dropWrites = true;
clearDropped.write(key, "");
CHECK(!bakeWriteLanded("", clearDropped.read(key)));
// Byte equality is the claim the line makes: a truncated or foreign value under the
// key is not the answer we wrote, and an unreadable key proves nothing at all.
CHECK(!bakeWriteLanded(answer, std::optional<std::string>(
answer.substr(0, answer.size() - 1))));
CHECK(!bakeWriteLanded(answer, std::optional<std::string>(
encodeBakeRequest(pending))));
CHECK(!bakeWriteLanded(answer, std::nullopt));
CHECK(!bakeWriteLanded("", std::optional<std::string>("leftover")));
}
// --- Every outcome bake_land actually emits survives the key round trip -------------
// The landing writes these and the instrument reads them back; a field the encoder and
// the decoder disagreed about would strand exactly the bake that produced it.
@@ -623,9 +764,22 @@ int main() {
writeFailed.status = BakeStatus::Failed;
writeFailed.message = "could not write the bake into the bank folder";
// A landing the project would not persist is answered as a FAILURE, not as the Ok
// it was on its way to being: the entry exists in memory only, so an instance that
// adopted it would be pointing at something no reload will have.
BakeOutcome unpersisted = noProject;
unpersisted.status = BakeStatus::Failed;
unpersisted.message =
"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";
BakeOutcome partial = noProject;
partial.status = BakeStatus::Failed;
partial.message = "the landing failed while writing: bad allocation";
for (const BakeOutcome& emitted :
{added, replaced, deduped, noProject, stagedMissing, noSource, indexRejected,
wrongProject, writeFailed}) {
wrongProject, writeFailed, unpersisted, partial}) {
const auto back = decodeBakeOutcome(encodeBakeOutcome(emitted));
CHECK(back.has_value());
CHECK(back.has_value() && *back == emitted);