fix(S4): close graveyard epoch ordering race; add canvas-clip + stride integration tests

Tag LoadedInstrument with installedAt; process() publishes that field (not a re-read
of reloadGeneration_) so the pruner's displacedAt <= seen bound is airtight. Also fixes
sampleRowHitTest canvas.bottom over-read and adds interleave-stride + canvas-clip tests.
This commit is contained in:
2026-07-26 16:57:48 -04:00
parent 0cde457224
commit b4b64ce68f
5 changed files with 191 additions and 19 deletions
+39 -10
View File
@@ -2,6 +2,7 @@
#include "reasampler_processor.h"
#include <algorithm>
#include <cstdint>
#include <fstream>
#include <vector>
@@ -121,7 +122,6 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
int32 got = 0;
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
bytes.insert(bytes.end(), chunk, chunk + got);
if (got < static_cast<int32>(sizeof(chunk))) break;
}
setSelectedSampleId(deserializeSelection(bytes));
// Rebuild from the restored selection (off-thread — setState is a load-time call).
@@ -133,8 +133,9 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
if (!state) return kResultFalse;
const std::vector<std::uint8_t> bytes = serializeSelection(selectedSampleId());
if (!bytes.empty()) {
state->write(const_cast<std::uint8_t*>(bytes.data()),
static_cast<int32>(bytes.size()), nullptr);
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
static_cast<int32>(bytes.size()), nullptr);
if (wr != kResultOk) return wr;
}
return kResultOk;
}
@@ -155,6 +156,10 @@ std::string ReaSamplerProcessor::reloadFromBank() {
// thread — process() only touches the atomic.
std::lock_guard<std::mutex> lock(reloadMutex_);
// Mint this reload's generation number first so we can stamp the built instrument
// with it before publishing. Under reloadMutex_ no other reload races here.
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
// 1. Read the live bank + resolve the project dir over the bridge (allocates,
// calls REAPER — fine here, off-thread).
std::optional<std::string> banksJson =
@@ -187,7 +192,7 @@ std::string ReaSamplerProcessor::reloadFromBank() {
static_cast<int>(layout.sampleRate), sel->rootNote,
sel->loop);
built = std::make_unique<LoadedInstrument>(
std::move(km), kMaxVoices, tier0Adsr(sampleRate_));
std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen);
// Record which id actually resolved so a first-sample fallback
// (empty stored id) becomes the concrete selection.
resolvedId = selectedSampleId();
@@ -198,19 +203,43 @@ std::string ReaSamplerProcessor::reloadFromBank() {
}
// 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the
// graveyard (process may still be reading it this block — it is reclaimed only when
// process is stopped, in setActive(false)/terminate). A null `built` (no bank /
// unreadable WAV) installs silence. `built` is heap-owned; release() hands
// ownership to the atomic, and the exchanged pointer is re-owned by the graveyard.
// graveyard tagged with this generation (process may still be mid-block reading
// it). A null `built` (no bank / unreadable WAV) installs silence.
// `built` is heap-owned; release() hands ownership to the atomic, and the
// exchanged pointer is re-owned by the graveyard.
//
// Bounded reclaim: prune graveyard entries where displacedAt <= seen, where seen
// is the last generation process() published. process() publishes inst->installedAt
// (not a re-read of reloadGeneration_), so seen == D means process holds the
// instrument installed at gen D. An entry with displacedAt == D was displaced by
// reload D, which installed that very successor — process cannot be holding the
// displaced entry. The pruning condition is therefore <= (see header for the full
// proof). Remaining entries drain at setActive(false) / terminate() when process
// is guaranteed stopped.
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
graveyard_.erase(
std::remove_if(graveyard_.begin(), graveyard_.end(),
[seen](const GraveyardEntry& e) { return e.displacedAt <= seen; }),
graveyard_.end());
LoadedInstrument* prev = live_.exchange(built.release());
if (prev) graveyard_.emplace_back(prev);
if (prev) graveyard_.push_back({gen, std::unique_ptr<LoadedInstrument>(prev)});
return resolvedId;
}
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
// REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the
// whole block (a single atomic acquire).
// whole block (a single atomic acquire), then publish inst->installedAt so the off-
// thread graveyard pruner knows exactly which generation this block is holding.
//
// We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an
// ordering race: reading reloadGeneration_ after live_ could observe a generation
// newer than the pointer we actually hold, causing the pruner to free an instrument
// process is still reading. installedAt was set on the reload path before the atomic
// exchange that made the instrument visible, so it is always <= the generation of any
// instrument that could have been loaded after our acquire above.
LoadedInstrument* inst = live_.load(std::memory_order_acquire);
const std::uint64_t heldGen = inst ? inst->installedAt : 0;
processGeneration_.store(heldGen, std::memory_order_release);
// Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps
// events at block granularity (no per-event sample-offset split) — audible timing is