Skip to content

fix: stop leaking a native pattern composer on every parse - #294

Merged
piaskowyk merged 3 commits into
mainfrom
claude/pattern-composer-leak
Sep 16, 2026
Merged

piaskowyk merged 3 commits into
mainfrom
claude/pattern-composer-leak

Conversation

@piaskowyk

Copy link
Copy Markdown
Member

The bug

Playing a handful of audio-backed presets in a row — or replaying a single one — makes the app stop playing the clip and play the synthesized "haptics as sound" rendering instead. Restarting the app buys a few more plays, then it happens again.

Why

usePatternComposer().parse() leaks a native composer on every call.

PatternComposer_parsePattern / parsePatternWithSound each build a brand-new PatternComposer and file it under a fresh id (Haptics.mm, PulsarModule.kt). The hook overwrote patternId and never released the previous one; only the unmount cleanup released an id, the last one.

Each orphan keeps holding, on the single shared CHHapticEngine:

  • its registerAudioResource(url:) registration — releaseAudio() only runs on that composer's own next parse or dispose, which never comes;
  • one or two CHHapticPatternPlayers in the engine's 20-slot registry.

The engine's audio-resource capacity is finite, so after a few plays registerAudioResource throws, HapticEngineWrapper logs Error registering audio resource: … and returns nil, makeAudioEvent returns nil, hasSound goes false — and play() falls through to the AudioSimulator buffer, which is the audible rendering of the haptic curve. A fresh engine after a restart starts with empty capacity, hence "works for a few, then stops".

Replaying or seeking a single preset is enough to get there: both re-parse.

Android leaks the same way, one SoundPool/MediaPlayer per parse; it ends in silence rather than the simulator fallback.

The fix

  1. usePatternComposer.parse releases the composer it held before building the next one — the idiom createBundle.ts already uses. Fixes iOS and Android.
  2. PatternComposer.swift drops its own previous players before re-parsing. This was a second leak of the same family, reachable from the native SDK API, and it let the registry evict a player that was still playing. Order matters: players first, because Core Haptics refuses to unregister an audio resource a live player still holds.

Verification

  • New Jest test: 2 of 3 cases fail on main, all pass here.
  • New Swift test: records playerStops → 0 on main, passes here.
  • xcodebuild test -scheme Pulsar-Package: 63 tests in 11 suites, plus the 8 isolated CoreHaptics mock tests — all green.
  • tsc --noEmit clean.

`PatternComposer_parsePattern` and `parsePatternWithSound` each build a
brand-new native composer and file it under a fresh id. `parse()`
overwrote the hook's id and dropped the old one on the floor, so only
the last composer was ever released, on unmount.

Every orphan keeps its `registerAudioResource` registration alive on the
shared haptic engine — `releaseAudio()` only runs on that composer's own
next parse, which never comes. The engine's audio capacity is finite, so
after a handful of plays registration starts failing, `hasSound` falls
back to false, and the preset plays the AudioSimulator rendering of its
haptics instead of its own audio. Replaying or seeking a single preset
is enough to get there, because both re-parse.

Android leaks the same way, one SoundPool/MediaPlayer per parse.
`parse()` overwrote `continuousPlayerId`/`discretePlayerId` without
handing the old players back, so every re-parse left two dead players in
the engine's 20-slot registry. Once the registry filled, each new player
evicted the oldest — which could be one still playing.

Players are released before the audio resource: Core Haptics refuses to
unregister a resource a live player still holds, and a registration that
fails to unregister stays loaded in the engine.
@piaskowyk
piaskowyk force-pushed the claude/pattern-composer-leak branch from d860713 to 11ac8ff Compare September 16, 2026 12:08
@piaskowyk
piaskowyk merged commit dc0a711 into main Sep 16, 2026
1 check passed
@piaskowyk
piaskowyk deleted the claude/pattern-composer-leak branch September 16, 2026 12:16
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