fix: bake-answer messages stop asserting causes the classifier can't see

Rewords Cleared/Unanswered/ForeignRequest to name only observed facts and every live hypothesis instead of picking a winner; adds a guarded answeredOutcome accessor against a future unhandled BakeAnswerKind; folds an unreachable BakeScanKey/Context test state; fixes generation-stamp timing.
This commit is contained in:
2026-08-02 06:57:08 -04:00
parent 962ab64ef0
commit 7f3b00a646
6 changed files with 139 additions and 35 deletions
+13 -5
View File
@@ -127,6 +127,9 @@ std::optional<BakeOutcome> decodeBakeOutcome(const std::string& wire) {
BakeAnswer classifyBakeAnswer(const std::optional<std::string>& raw, BakeAnswer classifyBakeAnswer(const std::optional<std::string>& raw,
const BakeRequest& sent) { const BakeRequest& sent) {
BakeAnswer answer; BakeAnswer answer;
// `raw` folds three distinct bridge outcomes into one — absent, explicitly
// cleared, and an unreadable/oversized read — because the bridge cannot label
// which occurred. A caller's message for `Cleared` must not claim more than that.
if (!raw || raw->empty()) { if (!raw || raw->empty()) {
answer.kind = BakeAnswerKind::Cleared; answer.kind = BakeAnswerKind::Cleared;
return answer; return answer;
@@ -150,6 +153,11 @@ BakeAnswer classifyBakeAnswer(const std::optional<std::string>& raw,
return answer; return answer;
} }
const BakeOutcome* answeredOutcome(const BakeAnswer& answer) {
if (answer.kind != BakeAnswerKind::Answered || !answer.outcome) return nullptr;
return &*answer.outcome;
}
BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanKey& key, BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanKey& key,
std::int64_t nowSec) { std::int64_t nowSec) {
// Not a request: an outcome the writing instance has not collected yet, or a value // Not a request: an outcome the writing instance has not collected yet, or a value
@@ -161,11 +169,11 @@ BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanK
if (age > kMaxRequestAgeSeconds || age < -kMaxRequestAgeSeconds) if (age > kMaxRequestAgeSeconds || age < -kMaxRequestAgeSeconds)
return BakeScanVerdict::ClearStale; return BakeScanVerdict::ClearStale;
// Three tabs have to agree before anything may land: the tab the request was found in, // Two facts have to agree before anything may land: the loaded project is the one a
// the tab the session's book/ledger last loaded, and the tab a persist will write into. // persist would write into (loadedProjectIsActive), and this key's tab IS that loaded
// Landing on a disagreement writes one tab's bake into another's bank. // project (matchesLoadedProject — false by construction whenever no project is
const bool landable = session.sessionHasLoadedProject && // loaded, which is what folds the former three-way check into two).
session.loadedProjectIsActive && key.inLoadedProject; const bool landable = session.loadedProjectIsActive && key.matchesLoadedProject;
return landable ? BakeScanVerdict::Land : BakeScanVerdict::RefuseWrongProject; return landable ? BakeScanVerdict::Land : BakeScanVerdict::RefuseWrongProject;
} }
+26 -9
View File
@@ -92,7 +92,8 @@ enum class BakeAnswerKind {
ForeignOutcome, // a decodable outcome, but for another generation ForeignOutcome, // a decodable outcome, but for another generation
Unanswered, // this request, unchanged: nothing on the extension side read it Unanswered, // this request, unchanged: nothing on the extension side read it
ForeignRequest, // a request that is not ours — another instance shares this key ForeignRequest, // a request that is not ours — another instance shares this key
Cleared, // the key holds nothing: cleared without an answer Cleared, // the key holds nothing — absent, explicitly cleared, or a read
// failure at the bridge; these are not distinguishable from here
Undecodable, // neither record — a build whose wire this one does not read Undecodable, // neither record — a build whose wire this one does not read
}; };
@@ -105,9 +106,20 @@ struct BakeAnswer {
BakeAnswer classifyBakeAnswer(const std::optional<std::string>& raw, BakeAnswer classifyBakeAnswer(const std::optional<std::string>& raw,
const BakeRequest& sent); const BakeRequest& sent);
// The outcome iff `answer.kind == Answered`; nullptr in every other state. The ONE place
// the Answered-implies-outcome-is-set contract is enforced, so a caller can fail closed
// on a state this switch does not (yet) name instead of dereferencing `answer.outcome` on
// the strength of switch exhaustiveness alone — exhaustiveness a future BakeAnswerKind
// enumerator (BakeAnswerKind is documented append-only, like its BakeStatus neighbor)
// would silently break with no compiler diagnostic (no -Wswitch/-Werror configured).
const BakeOutcome* answeredOutcome(const BakeAnswer& answer);
// The whole chain is a call and a return inside ONE editor tick, so a request older than // The whole chain is a call and a return inside ONE editor tick, so a request older than
// this has no reader left. Landing one would bank it for nobody and leave an outcome // this has no reader left. Landing one would bank it for nobody and leave an outcome
// nobody collects in the .rpp forever. // nobody collects in the .rpp forever. The bound is only meaningful because both ends
// read the same wall clock (std::time) AND the instrument stamps `generation`
// immediately before publishing the request — after staging the WAV, so that write
// never eats into the budget.
inline constexpr std::int64_t kMaxRequestAgeSeconds = 30; inline constexpr std::int64_t kMaxRequestAgeSeconds = 30;
// The extension's per-key verdict on one scanned `rsbake_*` key. // The extension's per-key verdict on one scanned `rsbake_*` key.
@@ -118,19 +130,24 @@ enum class BakeScanVerdict {
Ignore, // not a request (an uncollected outcome, or a wire we do not read) Ignore, // not a request (an uncollected outcome, or a wire we do not read)
}; };
// The session's side of the verdict which tab the extension's book belongs to, and // The session's side of the verdict: whether the loaded project is the one REAPER will
// whether that tab is the one REAPER will persist into. // persist into. Whether a project is loaded at ALL is folded into
// BakeScanKey::matchesLoadedProject below rather than carried as a second flag here — a
// key can only ever match a project that is loaded, so a standalone "session has a
// project" flag on this side could disagree with the key's and describe a state the
// shell can never actually produce.
struct BakeScanContext { struct BakeScanContext {
bool sessionHasLoadedProject = false; // the session has polled a project bool loadedProjectIsActive = false; // the loaded project is REAPER's active tab
bool loadedProjectIsActive = false; // that project is REAPER's active tab
}; };
// The scanned key's side. Both flags are per-TAB, which is what makes a request found in // The scanned key's side per-TAB, which is what makes a request found in a background
// a background tab decidable without any REAPER type crossing into this module. // tab decidable without any REAPER type crossing into this module.
struct BakeScanKey { struct BakeScanKey {
bool decoded = false; // the value decoded as a BakeRequest bool decoded = false; // the value decoded as a BakeRequest
std::int64_t generation = 0; std::int64_t generation = 0;
bool inLoadedProject = false; bool matchesLoadedProject = false; // this key's tab IS the session's loaded project
// — false whenever the session has no loaded
// project, by construction (see BakeScanContext)
}; };
BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanKey& key, BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanKey& key,
+4 -1
View File
@@ -265,7 +265,10 @@ void RunResampleBake(ReaSamplerSession& session) {
// land is told why rather than silently ignored. // land is told why rather than silently ignored.
const void* loaded = session.loadedProject(); const void* loaded = session.loadedProject();
const void* active = EnumProjects(-1, nullptr, 0); const void* active = EnumProjects(-1, nullptr, 0);
const wire::BakeScanContext scanContext{loaded != nullptr, loaded == active}; // `loaded == active` alone is enough: every OpenProject::proj enumerated below is a
// real, non-null tab, so an unloaded `loaded` (nullptr) can never equal one and
// matchesLoadedProject (below) falls out false regardless of this flag's value.
const wire::BakeScanContext scanContext{loaded == active};
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr)); const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
std::vector<Answer> answers; std::vector<Answer> answers;
+1 -1
View File
@@ -108,7 +108,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether the extension never ran the landing, answered a stale generation, answered in a wire this build cannot read, cleared the request, or refused it. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. - `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether the extension never ran the landing, answered a stale generation, answered in a wire this build cannot read, cleared the request, or refused it. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. - `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)* - `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
+31 -10
View File
@@ -154,14 +154,18 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
static_cast<std::size_t>(audio.frameCount()), interleaved); static_cast<std::size_t>(audio.frameCount()), interleaved);
const std::string instanceGuid = processor.usageInstanceGuid(); const std::string instanceGuid = processor.usageInstanceGuid();
const std::int64_t stamp = static_cast<std::int64_t>(std::time(nullptr)); // Named for what it is used for here — the staged file's own name, nothing else.
// `request.generation` below takes its OWN, later timestamp: kMaxRequestAgeSeconds
// is a budget measured from the write, and re-using this one would silently spend it
// on the WAV write that happens in between.
const std::int64_t stageStamp = static_cast<std::int64_t>(std::time(nullptr));
// OUTSIDE the bank folder, always: the bank holds indexed captures only, and a stray // OUTSIDE the bank folder, always: the bank holds indexed captures only, and a stray
// file there would read as a prune orphan. // file there would read as a prune orphan.
std::error_code ec; std::error_code ec;
const fs::path stagedPath = const fs::path stagedPath =
fs::temp_directory_path(ec) / fs::temp_directory_path(ec) /
("reasampler_bake_" + instanceGuid + "_" + std::to_string(stamp) + ".wav"); ("reasampler_bake_" + instanceGuid + "_" + std::to_string(stageStamp) + ".wav");
if (ec) return fail("no writable temp directory for the staged render"); if (ec) return fail("no writable temp directory for the staged render");
const std::string staged = stagedPath.string(); const std::string staged = stagedPath.string();
StagedFileGuard stagedGuard(staged); StagedFileGuard stagedGuard(staged);
@@ -175,7 +179,10 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
request.sourceDisplayName = sourceEntry->displayName; request.sourceDisplayName = sourceEntry->displayName;
request.ownUsageKey = usageKeyFor(instanceGuid); request.ownUsageKey = usageKeyFor(instanceGuid);
request.rootNote = plan.note; request.rootNote = plan.note;
request.generation = stamp; // Stamped here, immediately before the publish below — AFTER the WAV write above,
// which is what makes kMaxRequestAgeSeconds' budget (bake_wire.h) exclude staging
// time rather than eat into it.
request.generation = static_cast<std::int64_t>(std::time(nullptr));
const std::string key = bakeKeyFor(instanceGuid); const std::string key = bakeKeyFor(instanceGuid);
RequestKeyGuard keyGuard(bridge, key); RequestKeyGuard keyGuard(bridge, key);
@@ -198,24 +205,38 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
case wire::BakeAnswerKind::Unanswered: case wire::BakeAnswerKind::Unanswered:
return fail( return fail(
"the ReaSampler extension did not run the bake landing -- its action id " "the ReaSampler extension did not run the bake landing -- its action id "
"resolved but nothing read the request. Most likely the installed " "resolved but nothing read the request. Possible causes: the extension is "
"extension is older than this plugin: reinstall it and restart REAPER"); "older than this plugin and does not know this action; REAPER deferred "
"running the action past this call returning (unverified -- see "
"reaper_bridge.h); the extension ran but could not read its own request "
"key on this pass; or the action name resolved to an id no currently "
"loaded extension actually handles. Reinstalling the extension and "
"restarting REAPER is worth trying, but is not the only possible fix");
case wire::BakeAnswerKind::Undecodable: case wire::BakeAnswerKind::Undecodable:
return fail( return fail(
"the extension answered in a format this plugin does not read -- the " "the extension answered in a format this plugin does not read -- the "
"extension and ReaSampler 9000 are from different builds"); "extension and ReaSampler 9000 are from different builds");
case wire::BakeAnswerKind::Cleared: case wire::BakeAnswerKind::Cleared:
return fail( return fail(
"the extension discarded the bake request as stale before answering it"); "the bake key came back empty -- either the request was cleared before "
"an answer was written, or this build could not read whatever was there");
case wire::BakeAnswerKind::ForeignRequest: case wire::BakeAnswerKind::ForeignRequest:
return fail( return fail(
"another ReaSampler 9000 instance is baking under this instance's key -- " "the bake key held a different pending request instead of an answer -- "
"the two were copied from one another; reload this one and retry"); "unexpected given the bake's single-threaded call flow; if this recurs, "
"note the exact steps and file it");
case wire::BakeAnswerKind::ForeignOutcome: case wire::BakeAnswerKind::ForeignOutcome:
return fail("the extension answered a different bake request"); return fail("the extension answered a different bake request");
} }
// Set by construction on Answered (bake_wire.h states the invariant at BakeAnswer). // wire::answeredOutcome is the guard, not switch exhaustiveness alone: the switch
const BakeOutcome& outcome = *answer.outcome; // above has no `default`, so a future BakeAnswerKind enumerator it doesn't yet handle
// would otherwise fall through to a raw `*answer.outcome` deref with nothing set.
const BakeOutcome* outcomePtr = wire::answeredOutcome(answer);
if (!outcomePtr)
return fail(
"the extension's answer was not one this build recognizes -- the extension "
"and ReaSampler 9000 may be from different builds");
const BakeOutcome& outcome = *outcomePtr;
if (outcome.status != BakeStatus::Ok) if (outcome.status != BakeStatus::Ok)
return fail(outcome.message.empty() ? std::string("the bake was refused") return fail(outcome.message.empty() ? std::string("the bake was refused")
: outcome.message); : outcome.message);
+64 -9
View File
@@ -259,9 +259,16 @@ int main() {
CHECK(classifyBakeAnswer(std::optional<std::string>(encodeBakeRequest(sibling)), sent) CHECK(classifyBakeAnswer(std::optional<std::string>(encodeBakeRequest(sibling)), sent)
.kind == BakeAnswerKind::ForeignRequest); .kind == BakeAnswerKind::ForeignRequest);
CHECK(classifyBakeAnswer(std::nullopt, sent).kind == BakeAnswerKind::Cleared); // Cleared folds two genuinely different bridge outcomes -- key absent, and key
CHECK(classifyBakeAnswer(std::optional<std::string>(""), sent).kind == // present but empty -- into ONE kind, because the bridge cannot label which
BakeAnswerKind::Cleared); // occurred. Both must land on the identical kind: a caller's message may claim
// no more than what the fold actually preserves.
const BakeAnswer clearedFromAbsent = classifyBakeAnswer(std::nullopt, sent);
const BakeAnswer clearedFromEmpty =
classifyBakeAnswer(std::optional<std::string>(""), sent);
CHECK(clearedFromAbsent.kind == BakeAnswerKind::Cleared);
CHECK(clearedFromEmpty.kind == BakeAnswerKind::Cleared);
CHECK(clearedFromAbsent.kind == clearedFromEmpty.kind);
CHECK(classifyBakeAnswer(std::optional<std::string>("rsbakeout9 whatever"), sent) CHECK(classifyBakeAnswer(std::optional<std::string>("rsbakeout9 whatever"), sent)
.kind == BakeAnswerKind::Undecodable); .kind == BakeAnswerKind::Undecodable);
@@ -273,12 +280,58 @@ int main() {
.outcome.has_value()); .outcome.has_value());
} }
// --- answeredOutcome: the guarded read, not switch exhaustiveness alone -------------
{
BakeRequest sent;
sent.instanceGuid = "abcd";
sent.generation = 42;
BakeOutcome landed;
landed.status = BakeStatus::Ok;
landed.sampleId = "bake-1";
landed.generation = sent.generation;
// The one state that may yield a non-null pointer, and it points at the SAME
// outcome classifyBakeAnswer decoded.
const BakeAnswer answered =
classifyBakeAnswer(std::optional<std::string>(encodeBakeOutcome(landed)), sent);
const BakeOutcome* ptr = answeredOutcome(answered);
CHECK(ptr != nullptr);
CHECK(ptr->sampleId == "bake-1");
// Every other real classification nulls out.
CHECK(answeredOutcome(classifyBakeAnswer(std::nullopt, sent)) == nullptr);
CHECK(answeredOutcome(classifyBakeAnswer(
std::optional<std::string>(encodeBakeRequest(sent)), sent)) == nullptr);
CHECK(answeredOutcome(classifyBakeAnswer(std::optional<std::string>("garbage"), sent))
== nullptr);
BakeOutcome foreign = landed;
foreign.generation = sent.generation + 1;
CHECK(answeredOutcome(classifyBakeAnswer(
std::optional<std::string>(encodeBakeOutcome(foreign)), sent)) == nullptr);
// The defensive arm: a hand-built Answered with no outcome set (what a future
// BakeAnswerKind enumerator falling through an un-updated switch would look like
// to this accessor) fails closed rather than being dereferenced.
BakeAnswer malformed;
malformed.kind = BakeAnswerKind::Answered;
CHECK(!malformed.outcome.has_value());
CHECK(answeredOutcome(malformed) == nullptr);
}
// --- kMaxRequestAgeSeconds: a regression pin on the bound itself --------------------
// The bound's meaning depends on the instrument stamping `generation` immediately
// before publishing (AFTER staging the WAV) -- a shell-side ordering fix this pure
// suite cannot observe directly. Pinning the literal at least catches a silent
// widen/narrow of the budget itself.
CHECK(kMaxRequestAgeSeconds == 30);
// --- The extension's per-key verdict over the open tabs ----------------------------- // --- The extension's per-key verdict over the open tabs -----------------------------
{ {
const std::int64_t now = 1893456000; const std::int64_t now = 1893456000;
// The session has polled a project and that project is REAPER's active tab — the // The session has polled a project and that project is REAPER's active tab — the
// steady state a project opened from disk reaches on the next timer tick. // steady state a project opened from disk reaches on the next timer tick.
const BakeScanContext loadedAndActive{true, true}; const BakeScanContext loadedAndActive{true};
BakeScanKey own{true, now, true}; BakeScanKey own{true, now, true};
CHECK(classifyBakeScan(loadedAndActive, own, now) == BakeScanVerdict::Land); CHECK(classifyBakeScan(loadedAndActive, own, now) == BakeScanVerdict::Land);
@@ -292,11 +345,13 @@ int main() {
// The loaded tab is no longer the active one, so a persist would write elsewhere: // The loaded tab is no longer the active one, so a persist would write elsewhere:
// even the loaded tab's own request is refused rather than half-landed. // even the loaded tab's own request is refused rather than half-landed.
CHECK(classifyBakeScan(BakeScanContext{true, false}, own, now) == CHECK(classifyBakeScan(BakeScanContext{false}, own, now) ==
BakeScanVerdict::RefuseWrongProject); BakeScanVerdict::RefuseWrongProject);
// The window between opening a project from disk and the first poll: no book is // The window before any project is loaded: matchesLoadedProject cannot be true for
// loaded yet, so nothing may land. // ANY key here — a key can only match a project that is loaded so this is the
CHECK(classifyBakeScan(BakeScanContext{false, false}, own, now) == // shell-reachable stand-in for "no book yet", not `own` (which the shell could
// never actually pair with an unloaded session).
CHECK(classifyBakeScan(BakeScanContext{false}, BakeScanKey{true, now, false}, now) ==
BakeScanVerdict::RefuseWrongProject); BakeScanVerdict::RefuseWrongProject);
// Stale in EITHER direction (a clock that moved backwards counts too). // Stale in EITHER direction (a clock that moved backwards counts too).
@@ -318,7 +373,7 @@ int main() {
BakeScanKey notARequest{false, 0, true}; BakeScanKey notARequest{false, 0, true};
CHECK(classifyBakeScan(loadedAndActive, notARequest, now) == CHECK(classifyBakeScan(loadedAndActive, notARequest, now) ==
BakeScanVerdict::Ignore); BakeScanVerdict::Ignore);
CHECK(classifyBakeScan(BakeScanContext{false, false}, notARequest, now) == CHECK(classifyBakeScan(BakeScanContext{false}, notARequest, now) ==
BakeScanVerdict::Ignore); BakeScanVerdict::Ignore);
} }