Merge bake-answer diagnosis: five named failure modes and a scan report that says what the landing saw
This commit is contained in:
@@ -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`.
|
||||
- `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, is what separates an extension that never ran the landing from one that refused) 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` are that same reading counted and spoken — the rationale lives at the type. The report is empty whenever a pass answered anybody, which makes its ABSENCE from the console evidence too: no line means the landing action never ran.
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "core/wire/bake_wire.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "core/version/app_version.h"
|
||||
#include "core/wire/wire.h"
|
||||
|
||||
@@ -32,6 +34,10 @@ BakeStatus statusFromWire(int raw) {
|
||||
return BakeStatus::Failed;
|
||||
}
|
||||
|
||||
std::string countOf(int n, const char* noun) {
|
||||
return std::to_string(n) + " " + noun + (n == 1 ? "" : "s");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string bakeActionLookupName() {
|
||||
@@ -122,4 +128,89 @@ std::optional<BakeOutcome> decodeBakeOutcome(const std::string& wire) {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
BakeAnswer classifyBakeAnswer(const std::optional<std::string>& raw,
|
||||
const BakeRequest& sent) {
|
||||
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()) {
|
||||
answer.kind = BakeAnswerKind::Cleared;
|
||||
return answer;
|
||||
}
|
||||
if (std::optional<BakeOutcome> outcome = decodeBakeOutcome(*raw)) {
|
||||
answer.kind = outcome->generation == sent.generation
|
||||
? BakeAnswerKind::Answered
|
||||
: BakeAnswerKind::ForeignOutcome;
|
||||
answer.outcome = std::move(outcome);
|
||||
return answer;
|
||||
}
|
||||
// Still a request: whether it is OURS is what separates "nothing read this key" from
|
||||
// "another instance overwrote it" — a persisted instanceGuid is copyable, so two
|
||||
// instances CAN name one key.
|
||||
if (const std::optional<BakeRequest> req = decodeBakeRequest(*raw)) {
|
||||
answer.kind =
|
||||
*req == sent ? BakeAnswerKind::Unanswered : BakeAnswerKind::ForeignRequest;
|
||||
return answer;
|
||||
}
|
||||
answer.kind = BakeAnswerKind::Undecodable;
|
||||
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,
|
||||
std::int64_t nowSec) {
|
||||
// Listed by the enumerator but not returned whole by the reader. Distinct from the
|
||||
// next case even though both leave the key alone: this one is the extension failing
|
||||
// to read a request that may well be there, and it is invisible from the other end.
|
||||
if (!key.readable) return BakeScanVerdict::IgnoreUnreadable;
|
||||
|
||||
// Not a request: an outcome the writing instance has not collected yet, or a value
|
||||
// from a build we do not read. The writer owns clearing its own key.
|
||||
if (!key.decoded) return BakeScanVerdict::IgnoreNotARequest;
|
||||
|
||||
// Either direction, so a clock moved backwards is caught too.
|
||||
const std::int64_t age = nowSec - key.generation;
|
||||
if (age > kMaxRequestAgeSeconds || age < -kMaxRequestAgeSeconds)
|
||||
return BakeScanVerdict::ClearStale;
|
||||
|
||||
// Two facts have to agree before anything may land: the loaded project is the one a
|
||||
// persist would write into (loadedProjectIsActive), and this key's tab IS that loaded
|
||||
// project (matchesLoadedProject — false by construction whenever no project is
|
||||
// loaded, which is what folds the former three-way check into two).
|
||||
const bool landable = session.loadedProjectIsActive && key.matchesLoadedProject;
|
||||
return landable ? BakeScanVerdict::Land : BakeScanVerdict::RefuseWrongProject;
|
||||
}
|
||||
|
||||
std::string describeBakeScan(const BakeScanTally& t) {
|
||||
if (t.answered > 0) return {};
|
||||
|
||||
std::string s = "ReaSampler resample: the landing action ran and scanned " +
|
||||
countOf(t.tabsScanned, "project tab") + ", and answered nothing. ";
|
||||
if (t.keysFound == 0) {
|
||||
s += "No pending bake request was visible to it at all -- if ReaSampler 9000 "
|
||||
"reported publishing one, the plugin and the extension are not reading the "
|
||||
"same project's ext state.";
|
||||
return s + "\n";
|
||||
}
|
||||
|
||||
s += "It found " + countOf(t.keysFound, "pending request key") + ", " +
|
||||
std::to_string(t.activeTabKeys) + " of them in the active tab";
|
||||
if (t.unreadable > 0)
|
||||
s += "; " + std::to_string(t.unreadable) + " could not be read back";
|
||||
if (t.notARequest > 0)
|
||||
s += "; " + std::to_string(t.notARequest) + " held something other than a request";
|
||||
if (t.staleCleared > 0)
|
||||
s += "; " + std::to_string(t.staleCleared) +
|
||||
" were past the age bound and were cleared unanswered";
|
||||
s += ".";
|
||||
if (t.activeTabKeys == 0)
|
||||
s += " The tab this bake was fired against held none of them.";
|
||||
return s + "\n";
|
||||
}
|
||||
|
||||
} // namespace reasampler::wire
|
||||
|
||||
@@ -79,4 +79,102 @@ std::string encodeBakeOutcome(const BakeOutcome& outcome);
|
||||
std::optional<BakeRequest> decodeBakeRequest(const std::string& wire);
|
||||
std::optional<BakeOutcome> decodeBakeOutcome(const std::string& wire);
|
||||
|
||||
// --- Reading the shared key, from either end -----------------------------------------
|
||||
//
|
||||
// One key carries both records, so what it holds after the action returned is the ONLY
|
||||
// evidence either side gets. The two classifiers below are that reading, stated once.
|
||||
|
||||
// What the instrument found under its own key once invokeExtensionAction returned. The
|
||||
// distinction that matters: `Unanswered` is the extension never having read the key at
|
||||
// all, which is a DIFFERENT fault from every refusal — a refusal is an outcome.
|
||||
enum class BakeAnswerKind {
|
||||
Answered, // a decodable outcome echoing this request's generation
|
||||
ForeignOutcome, // a decodable outcome, but for another generation
|
||||
Unanswered, // this request, unchanged: nothing on the extension side read it
|
||||
ForeignRequest, // a request that is not ours — another instance shares this key
|
||||
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
|
||||
};
|
||||
|
||||
struct BakeAnswer {
|
||||
BakeAnswerKind kind = BakeAnswerKind::Cleared;
|
||||
std::optional<BakeOutcome> outcome; // set iff Answered or ForeignOutcome
|
||||
};
|
||||
|
||||
// `raw` is the key's value after the invocation (nullopt = absent/empty).
|
||||
BakeAnswer classifyBakeAnswer(const std::optional<std::string>& raw,
|
||||
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
|
||||
// this has no reader left. Landing one would bank it for nobody and leave an outcome
|
||||
// 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;
|
||||
|
||||
// The extension's per-key verdict on one scanned `rsbake_*` key.
|
||||
enum class BakeScanVerdict {
|
||||
Land, // land it into the loaded project's bank
|
||||
RefuseWrongProject, // answer WrongProject — the book in memory belongs to another tab
|
||||
ClearStale, // no reader left: clear the key, never answer it
|
||||
IgnoreUnreadable, // the enumerator listed it, the reader could not return it whole
|
||||
IgnoreNotARequest, // read whole, but an uncollected outcome or a wire we do not read
|
||||
};
|
||||
|
||||
// The session's side of the verdict: whether the loaded project is the one REAPER will
|
||||
// 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 {
|
||||
bool loadedProjectIsActive = false; // the loaded project is REAPER's active tab
|
||||
};
|
||||
|
||||
// The scanned key's side — per-TAB, which is what makes a request found in a background
|
||||
// tab decidable without any REAPER type crossing into this module.
|
||||
struct BakeScanKey {
|
||||
bool readable = false; // the enumerated key's value came back WHOLE
|
||||
bool decoded = false; // that value decoded as a BakeRequest (implies readable)
|
||||
std::int64_t generation = 0;
|
||||
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,
|
||||
std::int64_t nowSec);
|
||||
|
||||
// What ONE scan pass actually saw, accumulated by the shell as it applies the verdicts
|
||||
// above. Counts only, so the report below is provable without a DAW. It exists because
|
||||
// every non-answering verdict leaves the asking instance with the same evidence — its own
|
||||
// request, untouched — and only these counts say which of them happened.
|
||||
struct BakeScanTally {
|
||||
int tabsScanned = 0;
|
||||
int keysFound = 0; // `rsbake_*` keys enumerated across every open tab
|
||||
int activeTabKeys = 0; // of those, in the tab the action was fired against
|
||||
int unreadable = 0; // IgnoreUnreadable
|
||||
int notARequest = 0; // IgnoreNotARequest
|
||||
int staleCleared = 0; // ClearStale
|
||||
int answered = 0; // an outcome was written back (a landing OR a refusal)
|
||||
int landed = 0; // of `answered`, the ones that reached the bank
|
||||
};
|
||||
|
||||
// The console sentence for a pass that answered NOTHING — the one state in which the
|
||||
// asking instance reports a no-answer and has nothing to go on. Empty string when
|
||||
// `answered > 0`, so the caller prints unconditionally and stays quiet on a pass that
|
||||
// spoke for itself. The line's absence is itself evidence: no line means the landing
|
||||
// action never ran.
|
||||
std::string describeBakeScan(const BakeScanTally& tally);
|
||||
|
||||
} // namespace reasampler::wire
|
||||
|
||||
Reference in New Issue
Block a user