Skip to content

fix(android): make onInit resilient to slow/broken TTS engines (ANR + OOM)#14

Open
ninjz wants to merge 1 commit into
mhpdev-com:mainfrom
ninjz:fix/android-tts-oninit-main-thread-anr
Open

fix(android): make onInit resilient to slow/broken TTS engines (ANR + OOM)#14
ninjz wants to merge 1 commit into
mhpdev-com:mainfrom
ninjz:fix/android-tts-oninit-main-thread-anr

Conversation

@ninjz

@ninjz ninjz commented Jun 30, 2026

Copy link
Copy Markdown

Related to #11 / #12: this keeps initLock off the TextToSpeech call path, so it doesn't reintroduce that ABBA deadlock. It fixes a different pair of crashes (a main-thread-enumeration ANR and a getVoices() OOM), so it's complementary — happy to rebase onto #12 if that lands first.

Problem

Two crashes on low-end Android share one root cause — the onInit SUCCESS branch enumerates voices/engines synchronously on the main thread:

  1. ANR — main thread stuck executing synthesizer.voices / synthesizer.engines (not lock-waiting; distinct from Android ANR/deadlock: ensureInitialized holds initLock while calling TextToSpeech APIs #11's deadlock). Observed on a 4 GB, Android 15 device.
  2. OOM crash — uncaught OutOfMemoryError from TextToSpeech.getVoices() on some cheap engines (seen on MediaTek/UMIDIGI Android 12).

Root cause

Android delivers the TextToSpeech OnInitListener on the main thread (SetupConnectionAsyncTask.onPostExecute). The SUCCESS branch then runs, synchronously on that thread:

  • synthesizer.engines
  • applyGlobalOptions()synthesizer.voices (when a voice is configured)
  • processPendingOperations(), which drains queued getAvailableVoices / getEngines / configure

These are synchronous Binder IPC round-trips to the engine process.

  • ANR: on a cold engine under memory pressure these take seconds; back-to-back on the UI thread they cross the 5 s ANR threshold.
  • OOM: setLanguage() / voices both hit getVoices(). Some engines return a pathological serialized Voice[] that OutOfMemoryErrors while deserializing — a runaway allocation, not memory pressure (seen with ~2.9 GB free). The existing catch (e: Exception) doesn't catch it (OOM is an Error, not an Exception), so it escaped uncaught and crashed the app.

Fix

  • Off the main thread. Run the enumeration + global-config + queue drain on a background thread. Config is applied before isInitialized flips, so a caller can't speak before config is applied. initLock guards only the flag flip — applyGlobalOptions() and the drain run lock-free, because holding initLock across a TextToSpeech call is the ABBA deadlock from Android ANR/deadlock: ensureInitialized holds initLock while calling TextToSpeech APIs #11. cachedEngines becomes @Volatile.
  • Catch the OOM. Widen catch (e: Exception)catch (e: Throwable) in applyGlobalOptions() and applyOptions() (the two getVoices()-triggering methods), and backstop the background thread with try/catch (Throwable) — an uncaught throwable on a raw Thread still kills the app.
  • Also resolve synthesizer.defaultEngine once in getEngines() instead of once per engine in the loop (one Binder IPC instead of N).

Testing

  • :compileReleaseKotlin → BUILD SUCCESSFUL.
  • The OOM guard is native Kotlin, not reachable from a JS test suite — it's validated by compileReleaseKotlin plus the reasoning above (OOM is a Throwable but not an Exception; widening the catch + the thread backstop close the uncaught path). I can't deterministically reproduce either crash (both sampled and device-specific), but the change removes all synchronous cross-process IPC from the UI thread, applies config before opening the barrier, keeps initLock off the TTS call path, and converts a getVoices() OOM from an app kill into a silent degrade.

@ninjz ninjz force-pushed the fix/android-tts-oninit-main-thread-anr branch 2 times, most recently from 47ea199 to 4eda7b1 Compare June 30, 2026 06:49
@ninjz ninjz changed the title fix(android): move TTS engine/voice enumeration off the main thread in onInit (ANR on low-end devices) fix(android): make onInit resilient to slow/broken TTS engines (ANR + OOM) Jun 30, 2026
@ninjz ninjz force-pushed the fix/android-tts-oninit-main-thread-anr branch from 4eda7b1 to 2238fd0 Compare June 30, 2026 06:54
… OOM)

Two crashes on low-end Android share one root cause: the onInit SUCCESS
branch enumerates voices/engines synchronously on the main thread.

Android delivers the TextToSpeech OnInitListener on the main thread
(SetupConnectionAsyncTask.onPostExecute). The SUCCESS branch then ran
synthesizer.engines, applyGlobalOptions() (-> synthesizer.voices) and
processPendingOperations() (which drains queued getAvailableVoices /
getEngines / configure) on that thread -- all Binder IPC round-trips to
the engine process.

ANR: on a cold engine under memory pressure these take seconds;
back-to-back on the UI thread they exceed the 5s ANR threshold (observed
on a 4GB Android 15 device). This is distinct from the mhpdev-com#11 deadlock --
the main thread is executing synthesizer.voices, not waiting on initLock.

OOM crash: setLanguage()/voices hit TextToSpeech.getVoices(), and some
cheap engines (seen on MediaTek/UMIDIGI Android 12) return a pathological
serialized Voice[] that OutOfMemoryErrors while deserializing -- a runaway
allocation, not memory pressure (seen with ~2.9GB free). The existing
catch (e: Exception) does not catch it (OOM is an Error, not an Exception),
so it escaped uncaught and crashed the app.

Fixes:
- Run the engine/voice enumeration, global config and queue drain off the
  main thread. Apply config before flipping isInitialized so callers can't
  speak before config is applied. initLock guards only the flag flip;
  applyGlobalOptions() and the drain run lock-free, since holding initLock
  across a TextToSpeech call is an ABBA deadlock (see mhpdev-com#11). cachedEngines
  becomes @volatile.
- Widen catch (e: Exception) -> catch (e: Throwable) in applyGlobalOptions()
  and applyOptions() so a getVoices() OOM degrades instead of crashing, and
  backstop the background thread with try/catch (Throwable).
- Resolve synthesizer.defaultEngine once in getEngines() instead of once
  per engine inside the loop.

Complements mhpdev-com#11 / mhpdev-com#12: keeps initLock off the TextToSpeech call path, so it
does not reintroduce that deadlock. Rebase onto mhpdev-com#12 if it lands first.
@ninjz ninjz force-pushed the fix/android-tts-oninit-main-thread-anr branch from 2238fd0 to 1440e75 Compare June 30, 2026 07:11
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