Skip to content

fix(ios): size the Runtime for the block CoreAudio actually renders - #35

Open
dunkbig wants to merge 1 commit into
tamlyn:mainfrom
dunkbig:fix/render-block-buffer-overrun
Open

dunkbig wants to merge 1 commit into
tamlyn:mainfrom
dunkbig:fix/render-block-buffer-overrun

Conversation

@dunkbig

@dunkbig dunkbig commented Sep 23, 2026

Copy link
Copy Markdown

The bug

On iOS the Runtime's block size is derived from AVAudioSession.IOBufferDuration:

int bufferSize = (int)round(outputFormat.sampleRate * session.IOBufferDuration);

At 44100 Hz with the granted IOBufferDuration of 0.0107 s that yields 470. But IOBufferDuration is only a hint — AVAudioSourceNode hands the render block power-of-two frame counts, and it was consistently asked for 512:

[Elementary] Runtime block size: 470 frames
[Elementary] BUFFER OVERRUN DETECTED: frameCount=512 > blockSize=470

GraphRenderSequence sizes every internal buffer to blockSize (GraphRenderSequence(blockSize) : bufferPool(blockSize)), while the render loops run to ctx.numSamples. frameCount was passed straight through to Runtime::process with no clamp, so each block wrote 42 floats past the end of every pool buffer, corrupting the adjacent choc::memory::Pool::Block metadata with sample values.

The crash then surfaced on the next graph edit, when Pool::reset() walked that corrupted block list:

-[Elementary applyInstructions:]
  elem::Runtime<float>::applyInstructions
  elem::Runtime<float>::buildRenderSequence
  elem::GraphRenderSequence<float>::reset
  elem::FloatBufferPool<float>::clear
  choc::memory::Pool::reset
  choc::memory::Pool::Block::~Block
EXC_BAD_ACCESS (SIGSEGV)
KERN_INVALID_ADDRESS at 0x3f8000003f800000

0x3f800000 is 1.0f — the faulting address is two packed samples sitting where a pointer should be, which is what pinned this down. Note the crash reports on the TurboModule queue, but that thread is the victim, not the cause: the audio thread had already corrupted the memory it walks.

This was 100% reproducible for us — four captures, identical stack and identical fault address.

The fix

Size the Runtime from maximumFramesToRender, which is the actual bound on frameCount, instead of the IOBufferDuration estimate, and stop the config-change path from shrinking it back down. The render block also now degrades to silence rather than overrunning if a larger block ever arrives, so any future mismatch is audible instead of memory-unsafe.

After the change: Runtime block size: 4096 frames (session estimate 470), and the guard never trips.

Two other fixes included

While auditing every runtime-> call site I found two more, both in the same file:

  1. processQueuedEvents was the only entry point not holding _runtimeMutex. It copies Runtime::rtRenderSeq, a plain std::shared_ptr that the realtime thread reassigns in Runtime::process. The control-block refcount is atomic, but the pointer pair isn't read atomically, so this is a data race. Events are now collected under the lock and delivered after releasing it — the render callback try_locks and outputs silence when it can't acquire, so holding the mutex across a JS bridge call would just trade the race for audible dropouts at the poll rate.

  2. applyInstructions dereferenced self.runtime without a null check on both the fast and cold paths, unlike setProperty. Checked under the lock rather than outside it, since handleEngineConfigChange reassigns self.runtime while holding the mutex.

Happy to split these into separate PRs if you'd prefer one change per PR — they're independent commits' worth of work, I just kept them together as they're all in Elementary.mm and came out of the same investigation.

Validation

Manual, on the iOS Simulator (iOS 26.5, 44100 Hz), against a real app consuming this library with event polling enabled.

  • Before: opening a project crashed every time.
  • After: no crash through project open, ~40 s of playback, meter events during playback, and a fader drag under simultaneous setProperty / render / poll contention. Zero silence-guard trips, so audio rendered throughout rather than the guard masking the problem.

Being upfront about what I did not do:

  • yarn typecheck / yarn lint / yarn test were not run. The change is Objective-C++ only and those cover src/; I also didn't want to create a node_modules in this checkout, as it's consumed via a file: dependency and that risks disturbing Metro resolution in the consuming app. Happy to run them if you'd like.
  • Not verified on a physical device — device signing was unavailable to me. The overrun is not simulator-specific in principle, though the exact 470 depends on the granted IOBufferDuration.
  • No automated test added; there's no existing harness for the render path. Suggestions welcome if you'd like one.
  • unloadAudioResource still calls pruneSharedResources() without a null check. Left alone deliberately — it's unreachable with a null runtime, since a key can only be in loadedResources after loadAudioResource, which is engine-init gated.

🤖 Generated with Claude Code

The Runtime's block size was derived from AVAudioSession.IOBufferDuration
(round(44100 * 0.0107) = 470), but IOBufferDuration is only a hint:
AVAudioSourceNode hands the render block power-of-two frame counts, and it
was consistently asked for 512. Elementary sizes every internal buffer to
blockSize (GraphRenderSequence(blockSize) : bufferPool(blockSize)) while the
render loops run to ctx.numSamples, so each block wrote 42 floats past the
end of every pool buffer and smashed the adjacent choc::memory::Pool::Block
pointer metadata with sample values. The next graph edit then walked that
block list from buildRenderSequence -> reset -> Pool::reset and dereferenced
0x3f8000003f800000 (two packed 1.0f floats), a deterministic EXC_BAD_ACCESS
that surfaced on the TurboModule queue even though the audio thread caused it.

Size the Runtime from maximumFramesToRender instead, which is the real bound
on frameCount, and keep the config-change path from shrinking it back down.
The render block now also degrades to silence rather than overrunning if a
larger block ever arrives, so a future mismatch is audible, not memory-unsafe.

Also fixes two latent defects found while auditing the runtime entry points:

- processQueuedEvents was the only caller not holding _runtimeMutex. It copies
  Runtime::rtRenderSeq, a plain std::shared_ptr the realtime thread reassigns
  in Runtime::process; the control-block refcount is atomic but the pointer
  pair is not read atomically, so this was a data race. Events are now
  collected under the lock and delivered after releasing it — the render
  callback try_locks and outputs silence when it cannot acquire, so holding
  the mutex across a JS bridge call would trade the race for audible dropouts
  at the poll rate.

- applyInstructions dereferenced self.runtime without a null check on both the
  fast and cold paths, unlike setProperty. Checked under the lock rather than
  outside it, since handleEngineConfigChange reassigns self.runtime while
  holding the mutex.

Validation: manual only, on the iOS Simulator (iOS 26.5, 44100 Hz) against
midicircuit-rn's dev variant, with event polling enabled. Before: opening a
playground crashed every time, same stack and same fault address across four
captures. After: "Runtime block size: 4096 frames (session estimate 470)",
zero silence-guard trips, and no crash through playground open, ~40s of
playback, mixer meters during playback, and a fader drag under simultaneous
setProperty / render / poll contention. No automated tests were run for this
module and none exist for the render path; not verified on a physical device,
as device signing was unavailable. unloadAudioResource still calls
pruneSharedResources without a null check — left alone as it is unreachable
with a null runtime, since loadAudioResource is engine-init gated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant