Motivation
The engine currently has no general-purpose mechanism for sharing data between the RT audio thread and NRT context that can be allocated/freed at runtime via OSC. AudioBus is preallocated at engine init; synth instance memory is RT-owned. There's no story for, e.g., a sample buffer that:
- is allocated/freed via OSC,
- is written by a record synth in RT,
- is read by play synths in RT,
- can be analyzed (onset detection) or processed (heavy DSP) in NRT.
This issue specifies a generic resource protocol — plugins can register new resource types — with AudioBuffer as the first concrete class. Resources are identified by integer id; consumers obtain a type-checked pointer via URI match (similar to a dynamic cast).
Scope
In scope:
- Generic resource definition registration via plugin API.
- OSC verbs for resource lifecycle.
- Synth integration: new
kMethcla_ResourcePort port kind.
- NRT-side use via a dispatch primitive that brackets the use with refcount.
- One concrete public type:
AudioBuffer (multi-channel sample buffer; a new public plugin-API type, not a rename of the engine-internal MultiChannelBuffer).
- Extension of
/node/set to accept i:value for resource ports (the existing f:value form for control ports is unchanged).
Out of scope (future):
- Per-plugin unload while resources of its class are live.
- Atomic refcount,
dying flag, sweeper-style reclamation — unnecessary given methcla's threading topology (see ADR draft "RT-only mutation of resource state").
- Reader/writer locks; engine-enforced acquire granularity.
- Mid-life resource swapping under a running synth without going through
connect().
- Adding a return value to
Methcla_SynthDef.construct so pure-C plugins can signal construct failure — useful but independent.
Prerequisite (separate issue): dead-code cleanup in src/Methcla/Audio/Resource.hpp plus ResourceIdAllocator → IdAllocator rename — tracked in #148. The names freed by that cleanup are reused in this design.
Design decisions
| # |
Decision |
| 1 |
Payload-mutability is per-class; the resource pointer published by the engine is itself immutable. ResourceDef declares Mutable or Immutable. Both values are informational (see ADR draft "Mutability is informational"). |
| 2 |
Default acquire granularity is per-synth-lifetime — acquire at construct, release at destroy, dereference the cached Methcla_Resource* per pass. The protocol equally permits per-pass acquire/release for synths that need tighter free-time bounds or hold the resource only intermittently. Refcount is single-writer RT in either pattern. |
| 3 |
Resource refs are a new port kind kMethcla_ResourcePort; runtime re-map via /node/set i:node-id i:port-index i:resource-id. Synth options may also carry resource ids for shape-hint cases consumed by port_descriptor. |
| 4 |
Engine owns a fixed-size resource pool (Options::maxNumResources). Per resource id it tracks {state, uri, resource: Methcla_Resource*, destroy_fn, refcount, free_pending}. Plugin owns the resource's heap allocation. |
| 5 |
Interface contract: a resource URI names a C struct layout declared in a plugin-supplied header. The engine treats Methcla_Resource* as opaque (mirroring Methcla_Synth* opacity for synth instance bodies). Consumers cast the pointer after URI match. The struct may be POD, function-pointer-heavy, or both — the engine doesn't care. |
| 6 |
All per-resource state mutation, refcount mutation, and lifecycle dispatch run on RT. NRT is a pure callback path for construct / destroy / perform_with_resources. No atomics needed (see ADR draft "RT-only mutation of resource state"). |
| 7 |
NRT-side use is bracketed by methcla_world_perform_with_resources (RT-side refcount++ on dispatch, auto-release after NRT callback returns). The Methcla_Resource* is valid only for the duration of one perform call. |
| 8 |
Synth-side bookkeeping is the plugin's responsibility (explicit acquire/release primitives). C++ wrapper provides RAII ResourceRef<T> for the per-synth-lifetime pattern plus a try_acquire flavor returning std::optional. |
| 9 |
Id encoding: plain int32 resource id; client picks ids (NodeId / AudioBusId precedent). |
| 10 |
Lifecycle notifications (/resource/ready, /resource/error, /resource/destroyed) originate from RT post-state-transition, dispatched via NRT — mirrors /node/done. Required to avoid races between a NRT direct-notify and a follow-up /synth/new. |
| 11 |
kMethcla_ResourcePort supports direction (Input / Output) as informational metadata; the engine does not enforce mutability against direction. Methcla_PortDescriptor stays unchanged — URI checking remains in the synth's connect() via methcla_world_resource_acquire(..., expected_uri). |
Plugin C API
Opaque resource handle and definition struct (added to include/methcla/plugin.h):
typedef void Methcla_Resource; // opaque handle to a plugin-allocated resource
// (mirrors `typedef void Methcla_Synth;`)
typedef int32_t Methcla_ResourceId;
typedef enum {
kMethcla_ResourceMutable,
kMethcla_ResourceImmutable
} Methcla_ResourceMutability;
typedef struct Methcla_ResourceDef {
const char* uri;
Methcla_ResourceMutability mutability;
// NRT context. Parse args, allocate the resource, return it.
// Return NULL on failure; engine notifies /resource/error.
Methcla_Resource* (*construct)(Methcla_Host* host,
const void* tag_buffer, size_t tag_size,
const void* arg_buffer, size_t arg_size);
// NRT context. Free the resource. Must be infallible.
void (*destroy)(Methcla_Host* host, Methcla_Resource* resource);
} Methcla_ResourceDef;
Methcla_Host gains one field, parallel to register_synthdef:
struct Methcla_Host {
/* ... existing fields ... */
void (*register_resource_def)(Methcla_Host*, const Methcla_ResourceDef*);
};
RT-side primitives
Methcla_World gains two fields:
struct Methcla_World {
/* ... existing fields ... */
// Returns the resource pointer after URI check; increments refcount.
// Returns NULL on URI mismatch, resource not Live, free_pending, or out of range.
Methcla_Resource* (*resource_acquire)(
Methcla_World*, Methcla_ResourceId, const char* expected_uri);
// Decrements refcount; if hits 0 and free_pending, posts destroy-cmd to NRT.
void (*resource_release)(Methcla_World*, Methcla_ResourceId);
};
Consumers cast the returned pointer to the typed interface struct, e.g. (AudioBuffer*)methcla_world_resource_acquire(world, id, METHCLA_AUDIO_BUFFER_URI).
NRT-side primitive
A new callback signature, distinct from Methcla_HostPerformFunction, with the resolved resource pointers passed as a separate argument:
typedef void (*Methcla_PerformWithResourcesFunction)(
Methcla_Host* host,
Methcla_Resource* const* resources, size_t num_resources,
void* user_data);
// On dispatch RT acquires each resource (refcount++ for each).
// If exclusive=true, dispatch fails unless every listed resource has refcount==1
// at the moment of acquire; on failure no acquires are performed.
// On NRT callback return, RT auto-releases all (refcount--).
void (*perform_with_resources)(
Methcla_World*,
const Methcla_ResourceId* ids, size_t num_ids,
bool exclusive,
Methcla_PerformWithResourcesFunction perform,
void* user_data);
(Field on Methcla_World, alongside the resource_acquire/resource_release pair shown above.)
C++ wrappers
Two pieces, both added to include/methcla/plugin.hpp in the Methcla::Plugin namespace.
ResourceRef<T> — consumer-side RAII handle
Implements the per-synth-lifetime pattern. The type parameter is the C++ wrapper class for the resource (see "First resource class" below), which carries the URI and the underlying C struct type:
// Throws methcla::ResourceAcquireError on failure.
methcla::ResourceRef<Methcla::Plugin::AudioBuffer> buf(world, opts.bufferId);
// std::optional, empty on failure.
auto buf = methcla::try_acquire<Methcla::Plugin::AudioBuffer>(world, opts.bufferId);
// In connect(): replace contents, return false on failure (no-op on failure).
bool ok = m_buf.try_replace(world, new_id);
Per-pass synths use the C primitives directly without the smart pointer (acquire at process entry, release before return); the C++ wrapper does not provide a per-pass helper.
The ResourceRef<T> name reuses the symbol freed by the dead-code cleanup in #148 (the previous unused ResourceRef<T> = intrusive_ptr<T> alias).
ResourceDef<Resource, Options> — plugin-author-side definition template
Parallel to Methcla::Plugin::SynthDef<Synth, Options, Ports, Flags>. Bridges the C ABI to a C++ class so resource authors write a normal RAII class:
template <class Resource, class Options = NoOptions>
class ResourceDef
{
static Methcla_Resource* construct(
Methcla_Host* host,
const void* tag_buffer, size_t tag_size,
const void* arg_buffer, size_t arg_size)
{
OSCPP::Server::ArgStream args(
OSCPP::ReadStream(tag_buffer, tag_size),
OSCPP::ReadStream(arg_buffer, arg_size));
typename Options::Type opts(args);
void* mem = methcla_host_alloc(host, sizeof(Resource));
try {
new (mem) Resource(HostContext(host), opts);
} catch (...) {
methcla_host_free(host, mem);
return nullptr;
}
return static_cast<Methcla_Resource*>(mem);
}
static void destroy(Methcla_Host* host, Methcla_Resource* resource) {
Resource* r = static_cast<Resource*>(resource);
r->~Resource();
methcla_host_free(host, r);
}
public:
void operator()(Methcla_Host* host, const char* uri,
Methcla_ResourceMutability mutability)
{
static const Methcla_ResourceDef kDef = {
uri, mutability, construct, destroy
};
host->register_resource_def(host, &kDef);
}
};
Resource-class authors then write:
class AudioBufferImpl : public ::AudioBuffer {
public:
AudioBufferImpl(Methcla::Plugin::HostContext host, const Options& opts);
~AudioBufferImpl();
};
Methcla::Plugin::ResourceDef<AudioBufferImpl, AudioBufferOptions>()(
host, METHCLA_AUDIO_BUFFER_URI, kMethcla_ResourceMutable);
— mirroring the existing Methcla::Plugin::SynthDef<...>()(host, uri) registration idiom.
OSC API
| Verb |
Args |
Direction |
Notes |
/resource/new |
s:definition-name i:resource-id [args] |
client → engine |
[args] is a single OSC array (matches /synth/new's [synth-options] convention). Parsed by plugin in NRT. |
/resource/free |
i:resource-id |
client → engine |
Destroy now if refcount==0; else mark free_pending. |
/resource/ready |
i:resource-id |
engine → client |
Resource is Live. |
/resource/error |
i:resource-id s:reason |
engine → client |
Construct failed; id returned to Free. |
/resource/destroyed |
i:resource-id |
engine → client |
Id returned to Free; reusable. |
Synth integration via /node/set — this issue extends the existing verb to accept i:value for the new resource-port case:
| Port kind |
OSC arg |
Notes |
kMethcla_ResourcePort |
i:value only |
new in this issue; f: against a resource port = replyError |
Resource state machine
/resource/new (validated, id reserved)
Free ──────────────────────────────────────────→ Constructing
▲ │
│ │ NRT construct returns
│ ┌─────────┴─────────┐
│ │ │
│ NULL ptr
│ │ │
│ NRT destroy returns ▼ ▼
│ (state → Free) (notify Live
│ /resource/error) │
│ │ │ /resource/free
│ │ │ refcount==0 → Destroying
│ ▼ │ refcount>0 → free_pending=true,
└────────────────────────────────────── Free │ drain via release path
▼
Destroying
│
│ NRT destroy returns
└────────→ Free
free_pending is set on a Live id when /resource/free arrives but refcount > 0; the last release to drain refcount then initiates Destroying. The same flag is set on a Constructing id if free arrives mid-construct; transition to Destroying happens immediately after Constructing → Live, so the id never sits Live-and-doomed beyond one transition.
Threading
| Event |
Thread |
Touches |
/resource/new arrives |
RT |
resource Free → Constructing; post NRT construct |
| NRT construct returns |
NRT → posts back |
sends publish-cmd to RT |
| RT drains publish-cmd |
RT |
resource → Live; post ResourceReadyNotification to NRT |
NRT dispatches /resource/ready |
NRT |
calls packet handler |
Synth bind in /synth/new or /node/set |
RT |
refcount++ |
Synth destruction (Node::free) |
RT |
refcount-- for each held resource |
/resource/free arrives |
RT |
if refcount==0 → Destroying + post NRT destroy; else free_pending=true |
| NRT destroy returns |
NRT → posts back |
RT marks resource Free; notifies via NRT |
perform_with_resources dispatch |
RT |
per-resource refcount++; post NRT perform |
| NRT perform returns |
NRT → posts back |
RT auto-releases all (refcount--) |
Critical ordering: EnvironmentImpl::process drains the OSC request queue before m_worker->perform(). Lifecycle notifications must therefore originate from RT after the state transition, dispatched via NRT. Sending notifications directly from NRT would let a client's follow-up /synth/new race the publish-cmd.
Failure modes
| Where |
Surfaces as |
/resource/new validate (id range / id in use / definition unknown) |
replyError, RT-synchronous |
Plugin construct returns NULL |
/resource/error i:id s:reason notification; id returned to Free |
/resource/free validate (id invalid / resource not Live) |
replyError |
Plugin destroy |
Contractually infallible |
Resource acquire fails in C++ synth's construct |
ResourceRef<T> throws → processMessage replyError on the /synth/new |
Resource acquire fails in pure-C synth's construct |
Plugin self-handles: log via methcla_world_log_line, store a "no-resource" state on the synth instance; synth produces benign output (e.g., silence) until destroyed. Cleaning this up would need a construct return value (out of scope here). |
Resource acquire fails in synth connect (re-map) |
Retain prior binding, log warning, silent to client |
perform_with_resources dispatch with exclusive=true and refcount>1 |
Synchronous fail; no acquires performed; client-visible error |
Mutation contract published to plugin authors
A ResourceDef declares its mutability as either Mutable (suits buffers) or Immutable (suits wavetables, lookup tables, FFT plans). Both values are informational. The engine does not enforce write rejection; the declaration documents the class's contract for consumers and for plugin authors implementing other resource classes.
For Mutable classes, NRT writes are safe only when nothing else holds the resource. Recommended pattern: process-into-fresh-slot — allocate destination Y, dispatch perform_with_resources({X, Y}, ...) reading X and writing Y, optionally re-bind synths from X to Y via /node/set, free X.
For in-place writes, use exclusive=true. The engine rejects dispatch if any other holder exists, catching lifecycle bugs at dispatch time.
RT-side mutation (record synths) on Mutable classes is the user's responsibility, controlled by node ordering. The engine does not detect this.
For Immutable classes, the contract is by convention: the published interface struct typically has no setter methods, and consumers do not cast away const to write. Engine-side enforcement was considered and rejected (see ADR draft "Mutability is informational").
First resource class: AudioBuffer
AudioBuffer is a new public plugin-API type, distinct from the engine-internal MultiChannelBuffer (src/Methcla/Audio/MultiChannelBuffer.hpp, used by the audio driver for I/O). The two are structurally similar (both wrap multi-channel float arrays) but architecturally separate: MultiChannelBuffer is engine internals; AudioBuffer is the plugin-facing ABI contract. MultiChannelBuffer is unchanged by this issue.
Public C header: include/methcla/plugins/audio_buffer.h
#define METHCLA_AUDIO_BUFFER_URI "http://methc.la/resources/AudioBuffer/v1"
typedef struct AudioBuffer {
size_t num_channels;
size_t num_frames;
size_t sample_rate;
float** data; // num_channels pointers, each to num_frames samples
} AudioBuffer;
Public C++ wrapper: include/methcla/plugins/audio_buffer.hpp
#include <methcla/plugins/audio_buffer.h>
#include <cstring>
namespace Methcla { namespace Plugin {
class AudioBuffer
{
::AudioBuffer* m_buf;
public:
using c_type = ::AudioBuffer;
static constexpr const char* uri() { return METHCLA_AUDIO_BUFFER_URI; }
explicit AudioBuffer(::AudioBuffer* buf) : m_buf(buf) {}
size_t numChannels() const { return m_buf->num_channels; }
size_t numFrames() const { return m_buf->num_frames; }
size_t sampleRate() const { return m_buf->sample_rate; }
float* const* data() const { return m_buf->data; }
float* channel(size_t i) const { return m_buf->data[i]; }
void zero() {
for (size_t c = 0; c < m_buf->num_channels; ++c)
std::memset(m_buf->data[c], 0, m_buf->num_frames * sizeof(float));
}
};
}}
The static uri() and c_type typedef let ResourceRef<T> discover both at compile time, so callers write ResourceRef<Methcla::Plugin::AudioBuffer>(world, id) and the wrapper handles the URI check and the cast.
Implementing plugin: plugins/audio_buffer.cpp
Lives alongside other built-in plugins (disksampler.cpp, sampler.cpp). Registers a Methcla_ResourceDef for AudioBuffer via the Methcla::Plugin::ResourceDef<> template:
- Mutability:
kMethcla_ResourceMutable.
- The implementation class derives from (or layout-extends) the public
::AudioBuffer C struct so that a Methcla_Resource* returned from construct casts cleanly to AudioBuffer* on the consumer side.
- Constructor parses the OSC args (
i:num_channels i:num_frames i:sample_rate), allocates the AudioBuffer struct + channel arrays via methcla_host_alloc_aligned.
- Destructor frees channel arrays and the struct.
Engine implementation notes
- Resource pool: internally a fixed array of an engine-private struct (
ResourceEntry or similar) at engine init; one entry per Methcla_ResourceId.
- All per-resource fields are plain types (no atomics);
state, refcount, free_pending, and the Methcla_Resource* are RT-only.
- NRT work runs on the engine's existing worker thread pool (
Utility::WorkerThread, kNumWorkerThreads = 2); the NRT↔RT command FIFOs are the existing m_worker->sendToWorker / sendFromWorker channels, which already handle multi-producer enqueue via internal locking when needsLock = true.
- Multi-worker caveat for plugin authors: Resource-def callbacks (
construct, destroy, and the perform callback passed to perform_with_resources) run on the worker pool and can execute concurrently across different worker threads for different resources. The engine's own NRT-side APIs (methcla_host_alloc, methcla_host_free, the NRT→RT command channels) are thread-safe. Plugin authors who introduce plugin-side shared state (e.g., a static cache, a global file-handle pool) must synchronize that state themselves. The engine does not serialize NRT callbacks.
- Notifications follow the existing
Notification / NodeNotification pattern in EngineImpl.hpp — define ResourceReadyNotification, ResourceErrorNotification, ResourceDestroyedNotification.
- Synth allocation layout is unchanged at the engine level (no engine-side per-synth resource binding table; the plugin owns its refs).
- URI comparison in
resource_acquire is a strcmp; interning at register_resource_def time (so subsequent comparisons are pointer-equality) is a possible future optimization.
Engine shutdown ordering
- Stop accepting new OSC.
- Free all nodes (synth destructors release their resource refs).
- For each remaining Live resource, force-destroy via NRT.
- Wait for any in-flight Constructing resources to finish; immediately destroy.
- Drain NRT command FIFOs.
- Tear down resource pool.
Resource classes remain valid for the lifetime of the engine that registered them. Per-plugin unload is not supported in this iteration.
CONTEXT.md updates (apply when implementing)
- Add Resource entry: "A plugin-registered shared data object managed by the Engine, identified by an integer id and a type URI."
_Avoid_: instance (a Synth is an instance; a Resource is the thing the plugin allocates).
- Add ResourceDef entry mirroring SynthDef: "A Resource type registered by a Plugin. Describes the constructor, destructor, and interface URI for one kind of shared Resource."
_Avoid_: resource class, resource type.
- Add AudioBuffer entry: "A multi-channel sample-data Resource. Used for recording and playback by Synths."
_Acceptable shorthand: buffer, in code.
- Narrow AudioBus
_Avoid_ from channel, buffer, bus (acceptable shorthand in code) to channel, bus (acceptable shorthand in code) — buffer drops out (it now names a distinct concept).
Other documentation updates (apply when implementing)
Human-facing:
docs/osc-api.md — document the /resource/* verbs and the /node/set i:value form for resource ports.
docs/architecture.md — extend the runtime / routing overview to include Resources alongside Synths and AudioBuses.
docs/usage.md — add an example showing alloc → bind → use → free of an AudioBuffer with a record + play synth pair.
CHANGELOG.md — entry under ## [Unreleased], per the project conventions in CLAUDE.md.
Agent-facing:
CLAUDE.md "Domain and API references" — add a pointer to wherever the resource lifecycle lands (a section of architecture.md/osc-api.md, or a new doc).
ADR drafts
To file in docs/adr/ (number assigned at file-time) when the implementation PR lands.
RT-only mutation of resource state
Methcla's RT thread owns OSC dispatch, runs every state transition through EnvironmentImpl::process, and is responsible for synth destruction in Node::free (src/Methcla/Audio/Node.cpp:57-64). Every event that mutates a resource's engine-tracked state — creation, refcount changes from synth bind/unbind, free request, completion of NRT construct/destroy — originates from RT. The per-resource state, refcount, and free_pending fields are therefore single-writer (RT) and need no atomic synchronization. NRT is a pure async-callback for the plugin's construct/destroy and never reads or writes the resource's engine-tracked state directly; it returns results to RT via the existing NRT→RT command FIFO.
An alternative protocol that assumes RT-acquires + NRT-frees and uses an atomic refcount plus a dying flag with a seq_cst Dekker handshake was considered. Methcla's threading topology makes that machinery unnecessary. A future maintainer may try to "fix" the apparent absence of atomics — they should not. NRT use of a resource is always bracketed by methcla_world_perform_with_resources, whose RT-side acquire/release brackets the NRT callback, so the only mutator of refcount remains RT.
Mutability is informational
A Methcla_ResourceDef declares its mutability as kMethcla_ResourceMutable or kMethcla_ResourceImmutable. Both values are informational: the declaration documents the class's contract for consumers and for plugin authors implementing other classes, but the engine does not enforce write rejection. There is no read/write parameter on methcla_world_resource_acquire or methcla_world_perform_with_resources — the engine has no API surface at which to distinguish a read from a write.
Engine-side enforcement was considered: an alternative API would add a bool writable parameter to acquire and to perform_with_resources, with the engine rejecting writable acquires on Immutable classes. Rejected because (i) the use cases for Immutable resources (wavetables, lookup tables, FFT plans) are programmatically immutable — their published interface struct typically has no setter methods, so there's nothing the consumer could write even if it acquired with write intent; the contract is enforced at interface-design time, not at acquire time. (ii) Even with the parameter, enforcement would be best-effort — a consumer that lies about write-intent and casts away const defeats it. (iii) Adding the parameter inflates the API surface across every call site for a guarantee that doesn't actually hold.
For Mutable classes, race protection is layered: process-into-fresh-slot as the canonical safe pattern, perform_with_resources(..., exclusive=true) as a fail-fast check, and RT-side discipline (node ordering) for record-synth-style writes. Strict reader/writer enforcement on Mutable classes was also considered and rejected because it would break the motivating use case: a record synth (writer) and one or more play synths (readers) sharing the same buffer within a single audio pass.
Related issues
Motivation
The engine currently has no general-purpose mechanism for sharing data between the RT audio thread and NRT context that can be allocated/freed at runtime via OSC.
AudioBusis preallocated at engine init; synth instance memory is RT-owned. There's no story for, e.g., a sample buffer that:This issue specifies a generic resource protocol — plugins can register new resource types — with
AudioBufferas the first concrete class. Resources are identified by integer id; consumers obtain a type-checked pointer via URI match (similar to a dynamic cast).Scope
In scope:
kMethcla_ResourcePortport kind.AudioBuffer(multi-channel sample buffer; a new public plugin-API type, not a rename of the engine-internalMultiChannelBuffer)./node/setto accepti:valuefor resource ports (the existingf:valueform for control ports is unchanged).Out of scope (future):
dyingflag, sweeper-style reclamation — unnecessary given methcla's threading topology (see ADR draft "RT-only mutation of resource state").connect().Methcla_SynthDef.constructso pure-C plugins can signal construct failure — useful but independent.Prerequisite (separate issue): dead-code cleanup in
src/Methcla/Audio/Resource.hppplusResourceIdAllocator→IdAllocatorrename — tracked in #148. The names freed by that cleanup are reused in this design.Design decisions
MutableorImmutable. Both values are informational (see ADR draft "Mutability is informational").Methcla_Resource*per pass. The protocol equally permits per-pass acquire/release for synths that need tighter free-time bounds or hold the resource only intermittently. Refcount is single-writer RT in either pattern.kMethcla_ResourcePort; runtime re-map via/node/set i:node-id i:port-index i:resource-id. Synth options may also carry resource ids for shape-hint cases consumed byport_descriptor.Options::maxNumResources). Per resource id it tracks{state, uri, resource: Methcla_Resource*, destroy_fn, refcount, free_pending}. Plugin owns the resource's heap allocation.Methcla_Resource*as opaque (mirroringMethcla_Synth*opacity for synth instance bodies). Consumers cast the pointer after URI match. The struct may be POD, function-pointer-heavy, or both — the engine doesn't care.construct/destroy/perform_with_resources. No atomics needed (see ADR draft "RT-only mutation of resource state").methcla_world_perform_with_resources(RT-side refcount++ on dispatch, auto-release after NRT callback returns). TheMethcla_Resource*is valid only for the duration of one perform call.acquire/releaseprimitives). C++ wrapper provides RAIIResourceRef<T>for the per-synth-lifetime pattern plus atry_acquireflavor returningstd::optional./resource/ready,/resource/error,/resource/destroyed) originate from RT post-state-transition, dispatched via NRT — mirrors/node/done. Required to avoid races between a NRT direct-notify and a follow-up/synth/new.kMethcla_ResourcePortsupports direction (Input / Output) as informational metadata; the engine does not enforce mutability against direction.Methcla_PortDescriptorstays unchanged — URI checking remains in the synth'sconnect()viamethcla_world_resource_acquire(..., expected_uri).Plugin C API
Opaque resource handle and definition struct (added to
include/methcla/plugin.h):Methcla_Hostgains one field, parallel toregister_synthdef:RT-side primitives
Methcla_Worldgains two fields:Consumers cast the returned pointer to the typed interface struct, e.g.
(AudioBuffer*)methcla_world_resource_acquire(world, id, METHCLA_AUDIO_BUFFER_URI).NRT-side primitive
A new callback signature, distinct from
Methcla_HostPerformFunction, with the resolved resource pointers passed as a separate argument:(Field on
Methcla_World, alongside theresource_acquire/resource_releasepair shown above.)C++ wrappers
Two pieces, both added to
include/methcla/plugin.hppin theMethcla::Pluginnamespace.ResourceRef<T>— consumer-side RAII handleImplements the per-synth-lifetime pattern. The type parameter is the C++ wrapper class for the resource (see "First resource class" below), which carries the URI and the underlying C struct type:
Per-pass synths use the C primitives directly without the smart pointer (acquire at process entry, release before return); the C++ wrapper does not provide a per-pass helper.
The
ResourceRef<T>name reuses the symbol freed by the dead-code cleanup in #148 (the previous unusedResourceRef<T> = intrusive_ptr<T>alias).ResourceDef<Resource, Options>— plugin-author-side definition templateParallel to
Methcla::Plugin::SynthDef<Synth, Options, Ports, Flags>. Bridges the C ABI to a C++ class so resource authors write a normal RAII class:Resource-class authors then write:
— mirroring the existing
Methcla::Plugin::SynthDef<...>()(host, uri)registration idiom.OSC API
/resource/news:definition-name i:resource-id [args][args]is a single OSC array (matches/synth/new's[synth-options]convention). Parsed by plugin in NRT./resource/freei:resource-idrefcount==0; else markfree_pending./resource/readyi:resource-id/resource/errori:resource-id s:reason/resource/destroyedi:resource-idSynth integration via
/node/set— this issue extends the existing verb to accepti:valuefor the new resource-port case:kMethcla_ResourcePorti:valueonlyf:against a resource port =replyErrorResource state machine
free_pendingis set on a Live id when/resource/freearrives but refcount > 0; the lastreleaseto drain refcount then initiates Destroying. The same flag is set on a Constructing id if free arrives mid-construct; transition to Destroying happens immediately after Constructing → Live, so the id never sits Live-and-doomed beyond one transition.Threading
/resource/newarrivesResourceReadyNotificationto NRT/resource/ready/synth/newor/node/setrefcount++Node::free)refcount--for each held resource/resource/freearrivesrefcount==0→ Destroying + post NRT destroy; elsefree_pending=trueperform_with_resourcesdispatchrefcount++; post NRT performrefcount--)Critical ordering:
EnvironmentImpl::processdrains the OSC request queue beforem_worker->perform(). Lifecycle notifications must therefore originate from RT after the state transition, dispatched via NRT. Sending notifications directly from NRT would let a client's follow-up/synth/newrace the publish-cmd.Failure modes
/resource/newvalidate (id range / id in use / definition unknown)replyError, RT-synchronousconstructreturns NULL/resource/error i:id s:reasonnotification; id returned to Free/resource/freevalidate (id invalid / resource not Live)replyErrordestroyconstructResourceRef<T>throws →processMessagereplyErroron the/synth/newconstructmethcla_world_log_line, store a "no-resource" state on the synth instance; synth produces benign output (e.g., silence) until destroyed. Cleaning this up would need aconstructreturn value (out of scope here).connect(re-map)perform_with_resourcesdispatch withexclusive=trueand refcount>1Mutation contract published to plugin authors
A ResourceDef declares its mutability as either
Mutable(suits buffers) orImmutable(suits wavetables, lookup tables, FFT plans). Both values are informational. The engine does not enforce write rejection; the declaration documents the class's contract for consumers and for plugin authors implementing other resource classes.For Mutable classes, NRT writes are safe only when nothing else holds the resource. Recommended pattern: process-into-fresh-slot — allocate destination Y, dispatch
perform_with_resources({X, Y}, ...)reading X and writing Y, optionally re-bind synths from X to Y via/node/set, free X.For in-place writes, use
exclusive=true. The engine rejects dispatch if any other holder exists, catching lifecycle bugs at dispatch time.RT-side mutation (record synths) on Mutable classes is the user's responsibility, controlled by node ordering. The engine does not detect this.
For Immutable classes, the contract is by convention: the published interface struct typically has no setter methods, and consumers do not cast away const to write. Engine-side enforcement was considered and rejected (see ADR draft "Mutability is informational").
First resource class:
AudioBufferAudioBufferis a new public plugin-API type, distinct from the engine-internalMultiChannelBuffer(src/Methcla/Audio/MultiChannelBuffer.hpp, used by the audio driver for I/O). The two are structurally similar (both wrap multi-channel float arrays) but architecturally separate:MultiChannelBufferis engine internals;AudioBufferis the plugin-facing ABI contract.MultiChannelBufferis unchanged by this issue.Public C header:
include/methcla/plugins/audio_buffer.hPublic C++ wrapper:
include/methcla/plugins/audio_buffer.hppThe static
uri()andc_typetypedef letResourceRef<T>discover both at compile time, so callers writeResourceRef<Methcla::Plugin::AudioBuffer>(world, id)and the wrapper handles the URI check and the cast.Implementing plugin:
plugins/audio_buffer.cppLives alongside other built-in plugins (
disksampler.cpp,sampler.cpp). Registers aMethcla_ResourceDefforAudioBuffervia theMethcla::Plugin::ResourceDef<>template:kMethcla_ResourceMutable.::AudioBufferC struct so that aMethcla_Resource*returned from construct casts cleanly toAudioBuffer*on the consumer side.i:num_channels i:num_frames i:sample_rate), allocates theAudioBufferstruct + channel arrays viamethcla_host_alloc_aligned.Engine implementation notes
ResourceEntryor similar) at engine init; one entry perMethcla_ResourceId.state,refcount,free_pending, and theMethcla_Resource*are RT-only.Utility::WorkerThread,kNumWorkerThreads = 2); the NRT↔RT command FIFOs are the existingm_worker->sendToWorker/sendFromWorkerchannels, which already handle multi-producer enqueue via internal locking whenneedsLock = true.construct,destroy, and theperformcallback passed toperform_with_resources) run on the worker pool and can execute concurrently across different worker threads for different resources. The engine's own NRT-side APIs (methcla_host_alloc,methcla_host_free, the NRT→RT command channels) are thread-safe. Plugin authors who introduce plugin-side shared state (e.g., a static cache, a global file-handle pool) must synchronize that state themselves. The engine does not serialize NRT callbacks.Notification/NodeNotificationpattern inEngineImpl.hpp— defineResourceReadyNotification,ResourceErrorNotification,ResourceDestroyedNotification.resource_acquireis astrcmp; interning atregister_resource_deftime (so subsequent comparisons are pointer-equality) is a possible future optimization.Engine shutdown ordering
Resource classes remain valid for the lifetime of the engine that registered them. Per-plugin unload is not supported in this iteration.
CONTEXT.md updates (apply when implementing)
_Avoid_: instance (a Synth is an instance; a Resource is the thing the plugin allocates)._Avoid_: resource class, resource type._Acceptable shorthand: buffer, in code._Avoid_fromchannel, buffer, bus (acceptable shorthand in code)tochannel, bus (acceptable shorthand in code)—bufferdrops out (it now names a distinct concept).Other documentation updates (apply when implementing)
Human-facing:
docs/osc-api.md— document the/resource/*verbs and the/node/set i:valueform for resource ports.docs/architecture.md— extend the runtime / routing overview to include Resources alongside Synths and AudioBuses.docs/usage.md— add an example showing alloc → bind → use → free of anAudioBufferwith a record + play synth pair.CHANGELOG.md— entry under## [Unreleased], per the project conventions inCLAUDE.md.Agent-facing:
CLAUDE.md"Domain and API references" — add a pointer to wherever the resource lifecycle lands (a section ofarchitecture.md/osc-api.md, or a new doc).ADR drafts
To file in
docs/adr/(number assigned at file-time) when the implementation PR lands.RT-only mutation of resource state
Methcla's RT thread owns OSC dispatch, runs every state transition through
EnvironmentImpl::process, and is responsible for synth destruction inNode::free(src/Methcla/Audio/Node.cpp:57-64). Every event that mutates a resource's engine-tracked state — creation, refcount changes from synth bind/unbind, free request, completion of NRT construct/destroy — originates from RT. The per-resourcestate,refcount, andfree_pendingfields are therefore single-writer (RT) and need no atomic synchronization. NRT is a pure async-callback for the plugin'sconstruct/destroyand never reads or writes the resource's engine-tracked state directly; it returns results to RT via the existing NRT→RT command FIFO.An alternative protocol that assumes RT-acquires + NRT-frees and uses an atomic refcount plus a
dyingflag with a seq_cst Dekker handshake was considered. Methcla's threading topology makes that machinery unnecessary. A future maintainer may try to "fix" the apparent absence of atomics — they should not. NRT use of a resource is always bracketed bymethcla_world_perform_with_resources, whose RT-side acquire/release brackets the NRT callback, so the only mutator of refcount remains RT.Mutability is informational
A
Methcla_ResourceDefdeclares its mutability askMethcla_ResourceMutableorkMethcla_ResourceImmutable. Both values are informational: the declaration documents the class's contract for consumers and for plugin authors implementing other classes, but the engine does not enforce write rejection. There is no read/write parameter onmethcla_world_resource_acquireormethcla_world_perform_with_resources— the engine has no API surface at which to distinguish a read from a write.Engine-side enforcement was considered: an alternative API would add a
bool writableparameter to acquire and toperform_with_resources, with the engine rejecting writable acquires on Immutable classes. Rejected because (i) the use cases for Immutable resources (wavetables, lookup tables, FFT plans) are programmatically immutable — their published interface struct typically has no setter methods, so there's nothing the consumer could write even if it acquired with write intent; the contract is enforced at interface-design time, not at acquire time. (ii) Even with the parameter, enforcement would be best-effort — a consumer that lies about write-intent and casts away const defeats it. (iii) Adding the parameter inflates the API surface across every call site for a guarantee that doesn't actually hold.For Mutable classes, race protection is layered: process-into-fresh-slot as the canonical safe pattern,
perform_with_resources(..., exclusive=true)as a fail-fast check, and RT-side discipline (node ordering) for record-synth-style writes. Strict reader/writer enforcement on Mutable classes was also considered and rejected because it would break the motivating use case: a record synth (writer) and one or more play synths (readers) sharing the same buffer within a single audio pass.Related issues
IdAllocatorrename (prerequisite).