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:
@@ -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 0–1 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
|
||||
|
||||
Reference in New Issue
Block a user