From 1440e75ab3587b3300ee37a1e2c94210003db9e3 Mon Sep 17 00:00:00 2001 From: Calvin Cheng Date: Tue, 30 Jun 2026 14:20:08 +0800 Subject: [PATCH] fix(android): make onInit resilient to slow/broken TTS engines (ANR + 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 #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 #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 #11 / #12: keeps initLock off the TextToSpeech call path, so it does not reintroduce that deadlock. Rebase onto #12 if it lands first. --- .../main/java/com/speech/RNSpeechModule.kt | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/android/src/main/java/com/speech/RNSpeechModule.kt b/android/src/main/java/com/speech/RNSpeechModule.kt index 5ac708d..0221463 100644 --- a/android/src/main/java/com/speech/RNSpeechModule.kt +++ b/android/src/main/java/com/speech/RNSpeechModule.kt @@ -60,7 +60,7 @@ class RNSpeechModule(reactContext: ReactApplicationContext) : private lateinit var synthesizer: TextToSpeech private var selectedEngine: String? = null - private var cachedEngines: List? = null + @Volatile private var cachedEngines: List? = null @Volatile private var isInitialized = false @Volatile private var isInitializing = false @@ -260,12 +260,6 @@ class RNSpeechModule(reactContext: ReactApplicationContext) : synthesizer = TextToSpeech(reactApplicationContext, { status -> clearInitTimeout() if (status == TextToSpeech.SUCCESS) { - synchronized(initLock) { - isInitialized = true - isInitializing = false - initRetryCount = 0 - } - cachedEngines = try { synthesizer.engines } catch (e: Exception) { null } synthesizer.setOnUtteranceProgressListener(object : UtteranceProgressListener() { override fun onStart(utteranceId: String) { synchronized(queueLock) { @@ -338,8 +332,24 @@ class RNSpeechModule(reactContext: ReactApplicationContext) : } } }) - applyGlobalOptions() - processPendingOperations() + // onInit runs on the main thread; the engine/voice enumeration + // below ANRs on low-end devices, so run it off-thread. Apply config before + // flipping the flag, and keep initLock to the flag flip only — holding it + // across a TextToSpeech call deadlocks (upstream #11). + Thread { + // a raw Thread crashes the app on an uncaught Throwable (e.g. + // a getVoices() OOM on a broken engine) — backstop the body. + try { + cachedEngines = try { synthesizer.engines } catch (e: Throwable) { null } + applyGlobalOptionsInternal() + synchronized(initLock) { + isInitialized = true + isInitializing = false + initRetryCount = 0 + } + processPendingOperations() + } catch (t: Throwable) {} + }.start() } else { onInitFailure() } @@ -383,6 +393,12 @@ class RNSpeechModule(reactContext: ReactApplicationContext) : private fun applyGlobalOptions() { if (!isInitialized) return + applyGlobalOptionsInternal() + } + + // applyGlobalOptions() without the isInitialized guard, so the init worker can apply + // config before the barrier opens. Don't call under initLock — it touches TextToSpeech (#11). + private fun applyGlobalOptionsInternal() { try { globalOptions["language"]?.let { synthesizer.setLanguage(Locale.forLanguageTag(it as String)) @@ -396,7 +412,8 @@ class RNSpeechModule(reactContext: ReactApplicationContext) : globalOptions["voice"]?.let { voiceId -> synthesizer.voices?.find { it.name == voiceId }?.let { synthesizer.voice = it } } - } catch (e: Exception) {} + // Throwable, not Exception — getVoices() can OutOfMemoryError on some engines. + } catch (e: Throwable) {} } private fun applyOptions(options: Map) { @@ -409,7 +426,8 @@ class RNSpeechModule(reactContext: ReactApplicationContext) : temp["voice"]?.let { voiceId -> synthesizer.voices?.find { it.name == voiceId }?.let { synthesizer.voice = it } } - } catch (e: Exception) {} + // Throwable, not Exception — getVoices() can OutOfMemoryError on some engines. + } catch (e: Throwable) {} } private fun getValidatedOptions(options: ReadableMap): Map { @@ -605,11 +623,13 @@ class RNSpeechModule(reactContext: ReactApplicationContext) : ensureInitialized(promise) { val enginesArray = Arguments.createArray() val engines = cachedEngines ?: try { synthesizer.engines } catch (e: Exception) { null } + // resolve defaultEngine once (a Binder IPC) instead of once per engine. + val defaultEngine = try { synthesizer.defaultEngine } catch (e: Exception) { "" } engines?.forEach { engine -> enginesArray.pushMap(Arguments.createMap().apply { putString("name", engine.name) putString("label", engine.label) - putBoolean("isDefault", engine.name == try { synthesizer.defaultEngine } catch (e: Exception) { "" }) + putBoolean("isDefault", engine.name == defaultEngine) }) } promise.resolve(enginesArray)