Prove every ext-state write by reading the key back, so no guard is constant-true

SetProjExtState's return describes the whole extname. The persist and the instrument's publish now re-read their key; both refusals can finally fire.
This commit is contained in:
2026-08-02 13:14:49 -04:00
parent 0afb885987
commit 25390d5253
14 changed files with 231 additions and 112 deletions
+9 -1
View File
@@ -57,7 +57,7 @@ detail not covered there:
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::bakeWriteLanded`), because `SetProjExtState`'s return describes the whole extname's state and cannot speak for one key; an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that itself failed or overflowed reports Unknown, never a rejection, because "we could not check" is a different claim from "it did not land". Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing the project would not take is answered as a failure rather than as an `Ok` no reload would honour — `wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph.
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::extStateWriteLanded`, whose home is `core/wire/ext_state_read.h`); an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that overflowed, or a throw AFTER the `SetProjExtState` call, reports Unknown; a throw BEFORE it reports Rejected, because the write is then known not to have been made. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing the project would not take is answered as a failure rather than as an `Ok` no reload would honour — `wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph.
- `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked.
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
@@ -90,3 +90,11 @@ detail not covered there:
- **Realtime is the one capture path that accepts a multi-track selection**, and it is
correct to: its per-source-track sends sum in the one temp track, which is a real mix
rather than a stem collapse. The offline refusal above does not apply to it.
- **`bake_land`'s panel refresh and its generation bump can disagree after a throw.**
The refresh rides `bookChanged` outside the guarded scan, while the bump sits inside
it — so a pass that landed an entry and then threw before reaching the persist block
repaints the docked panel from the in-memory book without having bumped the bank
generation, and other open instances stay on the old generation until the next bump.
Accepted: the panel showing what the pass actually did is the more useful of the two,
and the next bank mutation reconciles it. Do not "fix" it by moving the refresh inside
the try — that would trade a stale generation for a stale panel.
+20 -13
View File
@@ -130,9 +130,11 @@ private:
// One entry's write-back, and the proof of whether it took. Guarded whole: it encodes into
// a fresh string and readKey grows a buffer toward 16 MB, and a throw escaping here would
// skip the ShowConsoleMsg for EVERY key — the silent console this action exists to make
// impossible. A throw is `Unknown` rather than a failure because the write may well have
// landed before the check did.
// impossible. Which proof a throw yields turns on whether SetProjExtState had run: before
// it, the write is KNOWN not to have been made (`Rejected`); after it, the write may well
// have landed and only the check failed (`Unknown`).
wire::BakeWriteProof writeBackOne(ScannedKey& entry, bool persisted) {
bool issued = false;
try {
std::string value; // empty = clear the key
if (entry.write == ScannedKey::Write::Answer) {
@@ -146,7 +148,8 @@ wire::BakeWriteProof writeBackOne(ScannedKey& entry, bool persisted) {
entry.outcome = refuseBake(
BakeStatus::Failed,
"the bake reached the bank in memory, but this pass's persist did not "
"report success, so this answer cannot promise a reload will find it",
"confirm its bank write, so this answer cannot promise a reload will "
"find it",
entry.outcome.generation);
// `detail` left empty on purpose: describeLanding's Unpersisted clause is
// that sentence's one home on the console side, and the message above is
@@ -158,16 +161,16 @@ wire::BakeWriteProof writeBackOne(ScannedKey& entry, bool persisted) {
value = wire::encodeBakeOutcome(entry.outcome);
}
SetProjExtState(entry.proj, kProjExtNamespace(), entry.key.c_str(), value.c_str());
// SetProjExtState's own return is the size of the whole extname's state, which
// `banks` alone keeps non-zero in every case a bake can reach, so only re-reading
// THIS key can speak for it. An oversized read proves nothing either way — folding
// it into bakeWriteLanded's absent case would report a clear off unread bytes.
issued = true;
// Only re-reading THIS key can speak for it (wire::extStateWriteLanded owns why).
// An oversized read proves nothing either way — folding it into that predicate's
// absent case would report a clear off unread bytes.
const KeyRead back = readKey(entry.proj, entry.key);
if (back.oversized) return wire::BakeWriteProof::Unknown;
return wire::bakeWriteLanded(value, back.value) ? wire::BakeWriteProof::Confirmed
: wire::BakeWriteProof::Rejected;
return wire::extStateWriteLanded(value, back.value) ? wire::BakeWriteProof::Confirmed
: wire::BakeWriteProof::Rejected;
} catch (...) {
return wire::BakeWriteProof::Unknown;
return issued ? wire::BakeWriteProof::Unknown : wire::BakeWriteProof::Rejected;
}
}
@@ -193,8 +196,12 @@ 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
bool persisted = false; // the pass's ONE persist reported success
bool persisted = false; // the pass's ONE persist confirmed its bank write
bool bookChanged = false; // some landing added or refreshed an entry
// A local, not a tally field: an entry counted here can still be re-answered as a
// failure by the write-back loop below, so this is a persist GATE and never a count
// anything reports.
bool anyLanded = false;
// 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
@@ -253,7 +260,7 @@ void RunResampleBake(ReaSamplerSession& session) {
request->generation);
} else {
LandingAttempt attempt = attemptLanding(session, open.dir, *request);
if (attempt.outcome.status == BakeStatus::Ok) ++tally.landed;
if (attempt.outcome.status == BakeStatus::Ok) anyLanded = true;
if (attempt.changedBook) bookChanged = true;
// Never Banked here, on ANY path: the pass's persist has not run yet,
// and bakeLandingAfterPersist is the one place that word is earned. A
@@ -273,7 +280,7 @@ void RunResampleBake(ReaSamplerSession& session) {
}
}
if (tally.landed > 0) {
if (anyLanded) {
// A dedup hit rides the persist too, because that is the only observation that
// can promote its landing — but it added nothing, so it earns neither the
// refresh every open instance pays for nor an undo point named after a bake