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
+33 -8
View File
@@ -37,12 +37,19 @@ namespace reasampler::vst {
// live and die together at a STABLE address — hence this is heap-allocated and neither
// copyable nor movable. The audio thread only ever reads it through an atomic pointer;
// it is built and destroyed off the audio thread.
//
// installedAt: the reloadGeneration_ value at which this instrument was atomically
// installed into live_. Set on the reload path before the exchange. process() publishes
// this field (not a fresh re-read of reloadGeneration_) so the published generation is
// exactly the generation of the instrument actually in hand for the block.
struct LoadedInstrument {
Keymap keymap;
VoiceEngine engine;
std::uint64_t installedAt = 0; // reload generation at which this was installed
LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr)
: keymap(std::move(km)), engine(maxVoices, keymap, adsr) {}
LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr,
std::uint64_t gen)
: keymap(std::move(km)), engine(maxVoices, keymap, adsr), installedAt(gen) {}
LoadedInstrument(const LoadedInstrument&) = delete;
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
@@ -105,13 +112,31 @@ private:
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
// NOT freed on the reload path: process() may still be mid-block reading it, and two
// rapid reloads could otherwise free a pointer process is using. Instead it is parked
// in `graveyard_` and reclaimed only when process is GUARANTEED stopped — at
// setActive(false) / terminate(), which the host never runs concurrently with
// process. The graveyard grows by one engine per reload during a session (bounded by
// user sample switches — a few objects), a deliberate leak-until-deactivate trade for
// a lock-free, race-free audio thread. Tier 2 can add epoch-based reclaim if needed.
// in `graveyard_` tagged with the reload generation at which it was displaced.
//
// Bounded reclaim: process() publishes inst->installedAt (the generation at which the
// held instrument was installed) via processGeneration_ — a single atomic store, RT-
// safe. The reload path prunes graveyard entries where displacedAt <= seen (where seen
// is the last published processGeneration_).
//
// Safety argument: an entry with displacedAt == D was displaced by reload D, which
// simultaneously installed its successor with installedAt == D. process() publishing
// seen == D means it holds that successor (or a later one). In either case, the
// displaced entry is not the pointer process is using, so freeing it is safe. The
// pruning condition is therefore <= (not strict <): an entry displaced at exactly the
// published generation is also provably unreachable.
//
// The graveyard's upper bound is the number of reloads since process last ran
// (typically 01 in normal use). Remaining entries drain at setActive(false) /
// terminate(), when the host guarantees process is stopped.
std::atomic<LoadedInstrument*> live_{nullptr};
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // freed only when stopped
std::atomic<std::uint64_t> reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process)
std::atomic<std::uint64_t> processGeneration_{0}; // generation last seen by process (written on audio thread, read off-thread)
struct GraveyardEntry {
std::uint64_t displacedAt = 0; // reloadGeneration_ value when this was displaced
std::unique_ptr<LoadedInstrument> instrument;
};
std::vector<GraveyardEntry> graveyard_; // drained on reclaim + setActive(false) + terminate
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
// The selected sample id (instance state). Off-thread only; a small mutex guards the