fix(android): make onInit resilient to slow/broken TTS engines (ANR + OOM)#14
Open
ninjz wants to merge 1 commit into
Open
fix(android): make onInit resilient to slow/broken TTS engines (ANR + OOM)#14ninjz wants to merge 1 commit into
ninjz wants to merge 1 commit into
Conversation
47ea199 to
4eda7b1
Compare
4eda7b1 to
2238fd0
Compare
… 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.
2238fd0 to
1440e75
Compare
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.
Problem
Two crashes on low-end Android share one root cause — the
onInitSUCCESS branch enumerates voices/engines synchronously on the main thread: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.OutOfMemoryErrorfromTextToSpeech.getVoices()on some cheap engines (seen on MediaTek/UMIDIGI Android 12).Root cause
Android delivers the
TextToSpeechOnInitListeneron the main thread (SetupConnectionAsyncTask.onPostExecute). The SUCCESS branch then runs, synchronously on that thread:synthesizer.enginesapplyGlobalOptions()→synthesizer.voices(when a voice is configured)processPendingOperations(), which drains queuedgetAvailableVoices/getEngines/configureThese are synchronous Binder IPC round-trips to the engine process.
setLanguage()/voicesboth hitgetVoices(). Some engines return a pathological serializedVoice[]thatOutOfMemoryErrors while deserializing — a runaway allocation, not memory pressure (seen with ~2.9 GB free). The existingcatch (e: Exception)doesn't catch it (OOM is anError, not anException), so it escaped uncaught and crashed the app.Fix
isInitializedflips, so a caller can'tspeakbefore config is applied.initLockguards only the flag flip —applyGlobalOptions()and the drain run lock-free, because holdinginitLockacross aTextToSpeechcall is the ABBA deadlock from Android ANR/deadlock: ensureInitialized holds initLock while calling TextToSpeech APIs #11.cachedEnginesbecomes@Volatile.catch (e: Exception)→catch (e: Throwable)inapplyGlobalOptions()andapplyOptions()(the twogetVoices()-triggering methods), and backstop the background thread withtry/catch (Throwable)— an uncaught throwable on a rawThreadstill kills the app.synthesizer.defaultEngineonce ingetEngines()instead of once per engine in the loop (one Binder IPC instead of N).Testing
:compileReleaseKotlin→ BUILD SUCCESSFUL.compileReleaseKotlinplus the reasoning above (OOM is aThrowablebut not anException; 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, keepsinitLockoff the TTS call path, and converts agetVoices()OOM from an app kill into a silent degrade.