pS remediation: legacy lift terminates on stale-id proof; displayName in v10 refs; refs-reader range fallbacks; load path keeps owned refs

This commit is contained in:
2026-07-28 12:17:36 -04:00
parent 261a6affa5
commit cb89dbf5c4
6 changed files with 213 additions and 36 deletions
+45 -8
View File
@@ -155,6 +155,16 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
// Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED
// sample refs — it needs no bank read, so it plays regardless of whether the
// extension's PROJEXTSTATE has parsed yet (or the extension exists at all).
//
// #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a
// pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic
// refreshRefsFromBank copies the refs in when the bank blob is readable by
// activation time, so an upgraded project plays on load without the instrument
// ever being opened (and the next save is self-contained). Residual load-order
// race, DAW-verifiable only: if the host activates this instance BEFORE the
// project's ext-state lines parse, the lift misses here and — with no editor open —
// nothing retries until the next activation or editor tick. MIGRATION NOTE: open a
// pre-v10 instrument once after upgrading if it restores silent.
reloadInstrument();
} else {
std::lock_guard<std::mutex> lock(reloadMutex_);
@@ -248,6 +258,9 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
std::lock_guard<std::mutex> lock(refsMutex_);
sampleRefs_ = cs.sampleRefs;
}
// A new blob is new facts: a staleness proof latched against the PREVIOUS state does
// not carry over (#A — the legacy lift gets one fresh run per restored state).
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
// Rebuild from the restored state (off-thread — setState is a load-time call).
reloadInstrument();
return kResultOk;
@@ -481,9 +494,11 @@ std::string ReaSamplerProcessor::reloadInstrument() {
bridge_.readReasamplerExtState(kProjExtBanksKey);
std::lock_guard<std::mutex> rl(refsMutex_);
if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids);
// Hygiene: the owned table tracks exactly what the instance currently plays, so a
// de-referenced sample's entry drops here (never grows with browsing history).
retainRefs(sampleRefs_, ids);
// The LOAD path never prunes the owned table: dropping entries here on a transient
// bank miss could destroy the owned intrinsics of the previous selection — the ONE
// copy that survives with the extension absent. Entries for de-referenced ids stay
// in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary,
// where getState filters its snapshot via retainRefs to what the instance plays.
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
}
const std::string projectDir = bridge_.activeProjectDir();
@@ -672,6 +687,25 @@ void ReaSamplerProcessor::retireIdleDrain() {
graveyard_.end());
}
bool ReaSamplerProcessor::legacyLiftShouldRun() {
// #A terminating guard for the pre-v10 legacy lift. The caller has already established
// refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before
// paying for a full reload. Once concluded, the steady state is this one relaxed load —
// no bank read, no parse, no reload churn.
if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false;
const LegacyLiftDecision decision = legacyLiftDecision(
bridge_.readReasamplerExtState(kProjExtBanksKey),
referencedSampleIds(selectedSampleId(), performanceMap()));
if (decision == LegacyLiftDecision::Stale) {
// Provably stale (the bank parses and knows none of the referenced ids): give up
// PERMANENTLY. A later bank change that re-introduces an id bumps the generation,
// and the genChanged reload refreshes the refs without consulting this latch.
legacyLiftConcluded_.store(true, std::memory_order_relaxed);
return false;
}
return true; // Retry (blob not readable yet) or Lift (a ref can be copied in)
}
ReaSamplerProcessor::BankSyncResult
ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
// OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call
@@ -759,13 +793,16 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
// editor tick until the lift lands: reloadInstrument folds the bank blob into the refs
// when readable, after which the table is non-empty and this never fires again (the
// next save is then self-contained). A deliberately-empty instance has no intent and
// never churns; a lift whose bank stays unreadable (or whose id went stale) retries a
// cheap null publish on the editor cadence only. This is a MIGRATION convenience for
// old projects, NOT a playback dependency — a v10 blob plays from its refs with no
// poll at all (pS).
// never churns; a bank that is not readable YET retries a cheap null publish on the
// editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob
// PARSES and no referenced id resolves in it, the ids are provably stale — there is
// nothing to lift, so the lift concludes permanently instead of churning a full bank
// read + reload every tick forever. This is a MIGRATION convenience for old projects,
// NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS).
bool legacyLift = false;
if (!genChanged && !result.applied && sampleRefs().empty()) {
legacyLift = !selectedSampleId().empty() || !performanceMap().empty();
const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty();
legacyLift = hasIntent && legacyLiftShouldRun();
}
if (genChanged || result.applied || legacyLift) {