Repository navigation
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
On iOS the Runtime's block size is derived from
AVAudioSession.IOBufferDuration:At 44100 Hz with the granted
IOBufferDurationof 0.0107 s that yields 470. ButIOBufferDurationis only a hint —AVAudioSourceNodehands the render block power-of-two frame counts, and it was consistently asked for 512:GraphRenderSequencesizes every internal buffer toblockSize(GraphRenderSequence(blockSize) : bufferPool(blockSize)), while the render loops run toctx.numSamples.frameCountwas passed straight through toRuntime::processwith no clamp, so each block wrote 42 floats past the end of every pool buffer, corrupting the adjacentchoc::memory::Pool::Blockmetadata with sample values.The crash then surfaced on the next graph edit, when
Pool::reset()walked that corrupted block list:0x3f800000is1.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 onframeCount, instead of theIOBufferDurationestimate, 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:processQueuedEventswas the only entry point not holding_runtimeMutex. It copiesRuntime::rtRenderSeq, a plainstd::shared_ptrthat the realtime thread reassigns inRuntime::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 callbacktry_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.applyInstructionsdereferencedself.runtimewithout a null check on both the fast and cold paths, unlikesetProperty. Checked under the lock rather than outside it, sincehandleEngineConfigChangereassignsself.runtimewhile 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.mmand 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.
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 testwere not run. The change is Objective-C++ only and those coversrc/; I also didn't want to create anode_modulesin this checkout, as it's consumed via afile:dependency and that risks disturbing Metro resolution in the consuming app. Happy to run them if you'd like.470depends on the grantedIOBufferDuration.unloadAudioResourcestill callspruneSharedResources()without a null check. Left alone deliberately — it's unreachable with a null runtime, since a key can only be inloadedResourcesafterloadAudioResource, which is engine-init gated.🤖 Generated with Claude Code