# Thread Safety Itc

> Audio thread safety rules, lock-free inter-thread communication patterns, and the audio event system in react-native-audio-api. Covers the three-thread model (JS / audio / worker), CrossThreadEventScheduler for JS→audio scheduling, IAudioEventHandlerRegistry for audio→JS events, AudioGraphManager for graph mutations, shadow state vs atomics decision table, TaskOffloader for off-thread work, and SpscChannel low-level API. Use when implementing cross-thread data flow, adding audio events, debugging thread-safety crashes or data races, or deciding which ITC primitive to use. Trigger phrases: "lock-free", "SPSC", "thread safety", "ITC", "cross-thread", "audio thread race", "scheduleAudioEvent", "invokeHandlerWithEventBody", "TaskOffloader", "off-thread", "SpscChannel", "CrossThreadEventScheduler", "shadow state", "atomic".

- Skill: `software-mansion/thread-safety-itc` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add software-mansion/thread-safety-itc`
- Raw SKILL.md: https://api.skillmd.com/api/skills/software-mansion/thread-safety-itc/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: software-mansion (https://skillmd.com/u/software-mansion)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/software-mansion/thread-safety-itc

---


# Skill: Thread Safety & Inter-Thread Communication

Three threads interact in this codebase. Every line of code that crosses a thread boundary must use the correct primitive or it is a bug.

**When in doubt about which ITC primitive to use → go to the Decision Table below first.**

---

## The Three Threads

| Thread | Alias | Runs |
|---|---|---|
| React Native JS thread | "JS thread" | User code, HostObject methods, `scheduleAudioEvent` calls |
| Audio thread | "audio thread" | `processNode()` — driven by Oboe (Android) / CoreAudio (iOS) |
| Worker threads | "off-thread" | FFmpeg decoding, file I/O, `TaskOffloader` tasks |

**Audio thread is real-time.** It has a hard deadline (~3ms at 44100 Hz, 128 frames). Missing it causes audible glitches.

---

## Audio Thread Contract

`processNode()` **MUST NOT**:
- Allocate or free memory (`new`, `delete`, `malloc`, `free`, any `push_back` that grows)
- Acquire any mutex (`std::mutex`, `std::lock_guard`, `std::unique_lock`)
- Make blocking syscalls (file I/O, socket I/O, `sleep`, `wait`)
- Call into JavaScript (no JSI calls, no `callInvoker_->invokeSync()`)
- Throw exceptions

**Preallocate everything in the constructor (JS thread).** The audio thread only uses what was already allocated.

---

## JS → Audio: `CrossThreadEventScheduler`

The standard way to send property updates from JS to the audio thread.

```cpp
// JS thread (HostObject setter):
auto oscillatorNode = std::static_pointer_cast<OscillatorNode>(node_);
auto event = [oscillatorNode, type](BaseAudioContext &) {
  oscillatorNode->setType(type);   // runs on audio thread
};
oscillatorNode->scheduleAudioEvent(std::move(event));
```

`scheduleAudioEvent()` is defined on `AudioNode`. It enqueues a lambda into the node's `CrossThreadEventScheduler<BaseAudioContext>`. The audio thread drains the queue at the start of each render cycle.

**Never assume immediate consistency** — by the time the audio thread processes the event, several render quanta may have passed.

---

## Audio → JS: `IAudioEventHandlerRegistry` and `EventCaller`

Send events from the audio thread back to JS (e.g. `ended`, `loopEnded`, `positionChanged`).

**Prefer `EventCaller<AudioEvent::X>`** — a small RAII helper templated on the event type. `dispatch()` requires a payload matching `EventPayloadFor<AudioEvent::X, Payload>` (see `AudioEventPayloadMapping.hpp`).

```cpp
// Node member (composition — one EventCaller per event)
EventCaller<AudioEvent::ENDED> onEndedEvent_{context->getAudioEventHandlerRegistry()};

// JS thread: HostObject setter forwards to a named method
void assignOnEndedCallbackId(uint64_t id) { onEndedEvent_.assignCallbackId(id); }

// Audio thread: fire when playback ends (EmptyPayload only for ENDED)
onEndedEvent_.dispatchEmpty();

// With typed payload — compile error if payload does not match the event
errorEvent_.dispatch(StringPayload{.name = "message", .reason = message});
positionChangedEvent_.dispatch(DoubleValuePayload{.value = position});
```

Throttled events (e.g. `positionChanged`) use `PositionChangedDispatcher`, which **contains** an `EventCaller` plus interval/flush logic — still composition, not node inheritance from `EventCaller`.

**Unregister lifecycle:**
- `EventCaller::~EventCaller()` unregisters if `callbackId != 0` when the node is destroyed
- `assignCallbackId(0)` unregisters when JS sets `onX = null`
- **HostObject destructor must call `assignOnXCallbackId(0)`** for every event wired on that node — the C++ node can outlive the HostObject, and the registry JSI function may be invalid after GC
- Clear callbacks in the **most-derived HostObject first** (each layer clears its own events; base destructors run afterward and clear parent events)
- Do **not** inherit `EventCaller` on multiple node bases (ambiguous API); add member `EventCaller` fields per event instead

**Time-delayed dispatch outside the node's lifetime** (e.g. firing `ended` at a context time the render path will not reach for this node): use `BaseAudioContext::deferEmptyEventDispatch(event, callbackId, dueTime)`. The context keeps a preallocated pending list drained each quantum in `processAudioEvents()`; the context clock is the timer (suspend pauses it, offline fires at render speed) and the context holds no node reference. Never use a detached timer thread for this — spawning a thread from an audio-event body runs on the audio thread (allocation + syscalls), and every capture choice has been a real bug in `AudioScheduledSourceNode::stop()`: raw `this` was a use-after-free, `weak_from_this().lock()` ran the node destructor on the timer thread (bypassing the graph's disposal path), and `std::move`-ing the `EventCaller` member raced the JS/GC-thread `assignCallbackId` paths (the registry `shared_ptr` inside `EventCaller` is only safe for concurrent access while it is never mutated — which is why `EventCaller` is non-movable).

Low-level registry API (used internally by `EventCaller`):

```cpp
audioEventHandlerRegistry_->dispatchEvent(AudioEvent::ENDED, callbackId, AudioEventPayload{...});
```

Internally calls `callInvoker_->invokeAsync()` — safe to call from the audio thread via `dispatchEvent`.

---

## Graph Mutations: `AudioGraphManager`

Connect/disconnect operations queue via `AudioGraphManager` (its own internal SPSC channel). The audio thread calls `graphManager_->preProcessGraph()` before each render pass to apply pending changes.

Do not call `AudioGraphManager` directly — go through `AudioNode::connect()` / `disconnect()`.

### Processable state is audio-thread-only

Per-quantum processable state (`ALWAYS_`/`CONDITIONAL_`/`NOT_PROCESSABLE`) is derived exclusively on the audio thread by `AudioGraph::settleProcessableState()` (a reverse-topological pull run inside `Graph::process()`), using only audio-thread-owned data: the topo-sorted node array, `InputPool` input lists, and `link_head` processable-links.

**Pitfall (fixed):** earlier, `HostGraph` AGEvents (`addEdge`/`removeEdge`/`removeAllEdges`) walked `HostGraph::Node::{inputs,outputs,linkedNodes}` on the audio thread to mark processable state incrementally. Those vectors mutate on the JS thread under `nodesMutex_` — a cross-thread race. AGEvents must never read HostGraph adjacency for processable state; they only mirror structural edges/links onto the audio graph. The host-side `linkedNodes` list is now kept solely so links can be scrubbed when a linked node is disposed.

---

## Decision Table

| Scenario | Correct pattern |
|---|---|
| JS sets a property → audio thread reads it | Shadow state in HostObject + `scheduleAudioEvent` |
| Audio thread fires an event → JS callback | `EventCaller::dispatch()` / `dispatchEmpty()` |
| JS connects/disconnects nodes | `AudioNode::connect()` → `AudioGraphManager` |
| Property written by audio thread, JS reads it | `std::atomic<T>` on C++ node; getter reads directly |
| Non-primitive, can be written by audio thread | Triple buffer (see `AnalyserNode` for reference) |
| CPU-heavy work, must not block JS or audio | `TaskOffloader` on a dedicated worker thread |
| Context lifecycle (`resume`/`suspend`/`close`) | `scheduleContextPromise` → `pendingPromisesOffloader_` |

---

## Off-Thread Work: `TaskOffloader`

For work that would block both the JS thread and the audio thread (decoding, file writing):

```cpp
TaskOffloader<MyWorkItem> offloader([](MyWorkItem item) {
  // runs on dedicated worker thread — allocs OK, blocking I/O OK
  item.process();
});
offloader.scheduleTask(std::move(workItem));
```

See the `utilities` skill for full API.

**Pitfall — file writer / recorder shutdown:** `TaskOffloader::shutdown()` drains the SPSC queue before joining the worker. Call it (or destroy the offloader) only after `isFileOpen_` is cleared so the audio thread stops enqueueing. Otherwise rotated or closed M4A segments lose seconds of buffered audio. Types with a `.slot` member use `slot == size_t max` as the shutdown sentinel.

---

## Driver synchronization (layered model)

Control-plane synchronization uses two layers — both are non-recursive `std::mutex`, never held on the audio thread.

| Layer | Location | Protects |
|---|---|---|
| Context | `BaseAudioContext::driverMutex_` (`AudioContext` + `OfflineAudioContext`) | `start` / `resume` / `suspend` / `close` (live); `resume` / `suspend` / `startRendering` (offline) — JS thread vs promise-pool |
| Engine | `AudioEngine` mutex (iOS only) | Process-wide `AVAudioEngine` graph: attach/detach, engine start/stop, interruptions, recorder paths |

`AudioContext::initialize()`, `createMediaElementSource()`, and `isDriverRunning()` are JS-thread-only — do not take `driverMutex_`. `getState()` returns the atomic control-thread state only (do not gate on `isDriverRunning()`); do not acquire `driverMutex_` from there.

On Android, `AudioPlayer::onErrorAfterClose` also takes `driverMutex_` because Oboe error callbacks bypass `AudioContext`.

**Live `AudioContext` render quiescence:** `currentRenders_` on `AudioContext` is incremented at the start of each platform I/O callback (`IOSAudioPlayer::deliverOutputBuffers` / `AudioPlayer::onAudioReady`) via a reference passed in `initialize()`, and decremented when the callback returns (RAII scope). `suspend()` and `close()` call `waitForRenderQuiescence()` (under `driverMutex_`) before `processAudioEvents()` / `cleanup()`. Platform drivers share the `CommonPlayer` abstract base (`common/cpp/audioapi/core/CommonPlayer.h`).

**Graph producer self-drain:** `Graph::enableProducerSelfDrain()` makes producer threads drain the event channels themselves after each enqueue; `disableProducerSelfDrain()` hands consumption back to the audio/render thread. Both flush the channels internally (no separate `processEvents()` call needed) and serialize with in-flight drains via `selfDrainMutex_`, because two producers can drain concurrently: the JS thread (mutations) and the GC finalizer thread (`removeNode`, which self-drains after its Channel B orphan send — otherwise a finalizer burst with no consumer fills the bounded channel and blocks forever, e.g. at process exit). Enable only when there is no audio/render consumer (realtime: construction + after `suspend`/`close` quiescence; offline: before `startRendering` and after a scheduled suspend); disable *before* starting the audio/render consumer; re-enable if start/resume fails.

**Context lifecycle promises:** HostObjects wrap JSI `Promise`s via `ContextPromiseResolver`
(`jsi/ContextPromiseResolver.hpp`); tasks are queued as `ContextPromiseTask`
(`core/types/ContextPromiseTask.h`). Lifecycle
ops (`resume` / `suspend` / `close` / offline start) are **control messages** on
`pendingPromisesOffloader_` via `scheduleContextPromise` from the JS thread
(`PromiseVendor::createPromise`, not the multi-worker `createAsyncPromise` — that would violate
SPSC single-producer). A dedicated `TaskOffloader` worker thread drains the queue under
`driverMutex_`, runs `collectDisposedNodes()` (host-graph ghost cleanup — never on the audio
thread), then executes the lifecycle body and settles the promise on the CallInvoker. The
`TaskOffloader` destructor joins the worker and drains any queued control messages — which is why
`~AudioContext` / `~OfflineAudioContext` MUST call `joinPendingPromiseWorker()` as their first
statement (and never while holding `driverMutex_`): default member destruction order would join the
worker last, letting drained tasks run against an already-destroyed player/graph/disposer. The
offline render thread is likewise owned (`renderThread_` + `stopRendering_` flag, joined in the
destructor, detach only when the last context reference drops on the render thread itself); the
render lambda must release its `resumePromise` right after resolving, since resolver callbacks
capture the context and would otherwise make the render thread its own context's last owner. When
the driver is stopped, `scheduleAudioEvent` still drains already-queued SPSC events then runs
the new event synchronously under `driverMutex_` (FIFO with prior messages). Control-message
bodies must not re-lock `driverMutex_` or `waitForRenderQuiescence()` while `currentRenders_ > 0`
(audio-callback self-deadlock). Apply the visible `state` attribute and settle the promise
together in the `ContextPromise` resolve task (CallInvoker), after driver work — so `.state`
still reads the prior value until settlement (needed when `resume()` then `suspend()` are issued
back-to-back).

---

## Common Mistakes

- **Reading `node_->field_` in a getter** when that field is written by the audio thread → use shadow state or atomics.
- **Calling `node_->method()` directly from a setter** → always schedule via `scheduleAudioEvent`.
- **Not clearing callback IDs in the HostObject destructor** → node keeps firing into a GC'd JSI function; call `assignOnXCallbackId(0)` from each event HostObject layer on teardown
- **`std::vector::push_back` in `processNode()`** → may allocate; preallocate in constructor.
- **`std::mutex` anywhere in `processNode()`** → deadlock risk and real-time violation.
- **Copying `shared_ptr` inside `processNode()`** — increments atomic refcount; capture before entering hot path.
- **Locking `initialize()` or graph factory methods** — `initialize()` runs synchronously during HostObject construction on the JS thread; node factories and `createMediaElementSource()` are synchronous JS calls. Only lifecycle methods that touch the driver or offline render thread need `driverMutex_`.
- **Locking only `AudioContext`** — iOS recorder, session, and interruption paths mutate the shared `AVAudioEngine` outside `AudioContext`; keep the `AudioEngine` mutex on those entry points. Offline render uses the same `driverMutex_` on `BaseAudioContext`.
- **Re-entering `driverMutex_` or the `AudioEngine` mutex on the same thread** — call `tryStartDriver()` directly from `resume()` instead of `start()`; use lock-free `isStreamRunning()` from `isDriverRunning()`. `AudioContext::start()` does not acquire `driverMutex_`; it asserts the lock is already held when the driver is not initialized (via `scheduleAudioEvent` synchronous path). When already initialized, `start()` is a lock-free no-op so `source.start()` on the audio thread does not take the mutex.

---

*Maintenance: see [maintenance.md](maintenance.md).*

