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
+1 -1
View File
@@ -80,7 +80,7 @@ This directory owns two cross-artifact contracts specifically:
- `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory.
- `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge.
- `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge.
- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`. It owns BOTH ends' reading of that key, since the key's contents are the only evidence either side gets: `classifyBakeAnswer` (instrument side — six kinds, of which `Unanswered`, the request still sitting there untouched, separates "nothing wrote an outcome over our key" from a refusal — it does NOT identify a landing that never ran, since a skipped key and a rejected answer-write look identical from here) and `classifyBakeScan` + `kMaxRequestAgeSeconds` (extension side — the per-key Land / RefuseWrongProject / ClearStale / IgnoreUnreadable / IgnoreNotARequest verdict over every open tab, stated without a REAPER type so the multi-tab matrix is unit-provable). `BakeScanTally` + `describeBakeScan` + `describeBakeKey` are that same reading counted and spoken — the rationale lives at the types. **The report's absence is NOT evidence the landing never ran**, and no sentence either artifact prints may say it is: `answered` is pass-wide and counts a QUEUED write, so a pass can answer some other key while skipping ours, or have our own answer's `SetProjExtState` rejected. The summary is therefore silent only when the pass answered somebody, left no key unanswered, and every answer's write landed; `describeBakeKey` prints one line per enumerated key regardless, which is the only thing that names WHICH key — the counts cannot.
- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`. It owns BOTH ends' reading of that key, since the key's contents are the only evidence either side gets: `classifyBakeAnswer` (instrument side — six kinds, of which `Unanswered`, the request still sitting there untouched, separates "nothing wrote an outcome over our key" from a refusal — it does NOT identify a landing that never ran, since a skipped key and a rejected answer-write look identical from here) and `classifyBakeScan` + `kMaxRequestAgeSeconds` (extension side — the per-key Land / RefuseWrongProject / ClearStale / IgnoreUnreadable / IgnoreNotARequest verdict over every open tab, stated without a REAPER type so the multi-tab matrix is unit-provable). `BakeScanTally` + `describeBakeScan` + `describeBakeKey` are that same reading counted and spoken — the rationale lives at the types. **The report's absence is NOT evidence the landing never ran**, and no sentence either artifact prints may say it is: `answered` is pass-wide and counts a QUEUED write, so a pass can answer some other key while skipping ours, or have our own answer's `SetProjExtState` rejected. The summary is therefore silent only when the pass answered somebody, left no key unanswered, and every answer was READ BACK from its own key; `describeBakeKey` prints one line per enumerated key regardless, which is the only thing that names WHICH key — the counts cannot. `bakeWriteLanded` is that read-back's verdict and the reason it has to exist: `SetProjExtState` returns the size of the whole extname's state, which `banks` alone keeps non-zero in every case a bake can reach, so nothing but re-reading the key can say whether THAT key took the value.
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns `classifyReaperSurface`, the prefix classifier mapping a `GetThingFromPoint` (info token, track-present) pair onto `core/ui/drag_out`'s `ReaperSurface`. Classifier ordering is load-bearing: the embed strip is matched before the `tcp`/`mcp` panel family, which now claims the WHOLE track panel rather than just its FX sub-elements. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it.
+48 -13
View File
@@ -214,9 +214,11 @@ std::string describeBakeScan(const BakeScanTally& t) {
if (t.notARequest > 0)
s += "; " + std::to_string(t.notARequest) +
" held something other than a request";
// "were cleared" would assert a write this count cannot see: whether each clear
// actually took is a per-KEY read-back, and describeBakeKey is where it is said.
if (t.staleCleared > 0)
s += "; " + std::to_string(t.staleCleared) +
" were past the age bound and were cleared unanswered";
" were past the age bound, so no reader was left to answer";
}
s += ".";
if (t.writeFailed > 0)
@@ -226,9 +228,31 @@ std::string describeBakeScan(const BakeScanTally& t) {
return s + "\n";
}
namespace {
// The Land verdict's own clause. Separate function so its own fail-closed default cannot
// be swallowed by the caller's switch — an unnamed enumerator returns empty either way.
std::string describeLanding(BakeLanding landing) {
switch (landing) {
case BakeLanding::Banked:
return "landed into the bank";
case BakeLanding::Unpersisted:
return "landed into the bank IN MEMORY ONLY -- this pass could not persist it, "
"so the project's saved bank state does not carry it";
case BakeLanding::Partial:
return "the landing failed after it had begun writing -- it may have left a "
"file in the bank folder and an entry in memory";
case BakeLanding::Refused:
return "the landing was refused";
}
return {};
}
} // namespace
std::string describeBakeKey(const std::string& key, const BakeKeyOutcome& outcome) {
const std::string head = "ReaSampler resample: " + key + " -- ";
std::string answered; // set only by the two verdicts that write an outcome back
std::string clause; // set only by the verdicts that write something back
switch (outcome.verdict) {
case BakeScanVerdict::IgnoreUnreadable:
return head + (outcome.oversized
@@ -240,25 +264,36 @@ std::string describeBakeKey(const std::string& key, const BakeKeyOutcome& outcom
"skipped: it holds something other than a pending request -- an answer "
"nobody has collected, or a wire this build does not read.\n";
case BakeScanVerdict::ClearStale:
return head +
"cleared unanswered: it was past the age bound, so no reader was left "
"for it.\n";
// The clear is a write like any other, so it is claimed only where it was read
// back: an unconfirmed one leaves the request standing for the next pass.
return head + (outcome.writeConfirmed
? "past the age bound with no reader left, so it was cleared "
"unanswered.\n"
: "past the age bound with no reader left, but the clear "
"could NOT be read back -- the key still holds it and the "
"next pass will see it again.\n");
case BakeScanVerdict::RefuseWrongProject:
answered = "refused: its project tab is not the one this extension has loaded";
clause = "refused";
break;
case BakeScanVerdict::Land:
answered = outcome.landed ? "landed into the bank" : "the landing was refused";
clause = describeLanding(outcome.landing);
break;
}
// Fails closed on a verdict this build has no word for, the same reason
// answeredOutcome exists: no -Wswitch is configured, so an appended enumerator would
// otherwise fall straight into the trailing clause and print half a sentence.
if (answered.empty()) return head + "a verdict this build has no word for.\n";
return head + answered +
(outcome.answerWritten
? ", and the answer was written back.\n"
: ", but the answer could NOT be written back -- the instance that asked "
"will report no answer.\n");
if (clause.empty()) return head + "a verdict this build has no word for.\n";
std::string s = head + clause;
if (!outcome.detail.empty()) s += " (" + outcome.detail + ")";
return s + (outcome.writeConfirmed
? ". The answer was written back.\n"
: ". The answer could NOT be written back -- the instance that asked "
"will report no answer.\n");
}
bool bakeWriteLanded(const std::string& written, const std::optional<std::string>& readBack) {
if (written.empty()) return !readBack || readBack->empty();
return readBack && *readBack == written;
}
} // namespace reasampler::wire
+34 -11
View File
@@ -176,29 +176,52 @@ struct BakeScanTally {
int answered = 0; // an outcome was QUEUED for write-back (a landing OR a
// refusal) — pass-wide, not per key, and not proof the write
// reached the project; `writeFailed` is that
int writeFailed = 0; // of `answered`, the ones SetProjExtState reported as not
// landed — an asking instance sees these as no answer at all
int writeFailed = 0; // of `answered`, the ones whose value could NOT be read back
// from their key afterwards — an asking instance sees these as
// no answer at all
int landed = 0; // of `answered`, the ones that reached the bank
};
// The console summary for one pass. Empty ONLY when the pass answered somebody AND left no
// key unanswered AND every answer's write landed — so the summary's absence means nothing
// went wrong, never that the action did not run. A silent console is NOT evidence the
// landing never ran; nothing here can observe that, and no caller may say it does.
// key unanswered AND every answer was read back from its own key — so the summary's absence
// means nothing went wrong, never that the action did not run. A silent console is NOT
// evidence the landing never ran; nothing here can observe that, and no caller may say it.
std::string describeBakeScan(const BakeScanTally& tally);
// How far a `Land` verdict actually got. Only `Banked` is a landing the project still holds
// after a reload: the book lives in memory and a pass that could not persist has changed
// nothing the .rpp will carry.
enum class BakeLanding {
Refused, // nothing was written and the book is untouched
Partial, // it threw AFTER it had begun writing — a file and/or an entry may exist
Unpersisted, // it reached the in-memory book, but the pass's persist did not happen
Banked, // in the book AND persisted into the project
};
// What ONE scanned key ended the pass in — the per-key half of the tally above, which
// counts but cannot name. Three of the fields are verdict-conditional; the phrasing
// counts but cannot name. Every field but the verdict is verdict-conditional; the phrasing
// function is the one place that pairing is spelled out.
struct BakeKeyOutcome {
BakeScanVerdict verdict = BakeScanVerdict::IgnoreUnreadable;
bool oversized = false; // IgnoreUnreadable only: the value exceeded the read
// ceiling rather than reading back empty
bool landed = false; // Land only: the bake reached the bank
bool answerWritten = false; // Land / RefuseWrongProject only: the outcome write
// reported landing in the project
bool oversized = false; // IgnoreUnreadable only: the value exceeded the read ceiling
// rather than reading back empty
BakeLanding landing = BakeLanding::Refused; // Land only
bool writeConfirmed = false; // Land / RefuseWrongProject / ClearStale: the write this
// verdict required — an outcome, or a clear — was READ
// BACK from the key afterwards (see bakeWriteLanded)
std::string detail; // the outcome's own message, so one line per key is
// self-contained; empty for the verdicts without one
};
// Did the write we just made take? `readBack` is what the key holds afterwards (nullopt =
// absent or unreadable). This exists because SetProjExtState's return is the size of the
// WHOLE extname's state — `banks`, `project_guid` and every rsusage_ key count toward it,
// and a bake can only reach a landing in a project whose `banks` is already populated — so
// that return is non-zero whether or not THIS key took the value. Reading the key back is
// the only per-key observation available under a shared extname. An empty `written` is a
// CLEAR, which lands as an absent-or-empty key rather than as those bytes.
bool bakeWriteLanded(const std::string& written, const std::optional<std::string>& readBack);
// One console line naming a key and what the pass did with it, ends in '\n'. Printed for
// EVERY enumerated key, answered or not, because the counts above cannot tell an instance
// which key was its own — and the key carries the asking instance's guid.