fix(announcer): never let a TTS failure escape as an uncaught exception - #60
Merged
Merged
Conversation
Two uncaught exceptions in a shipped TV app traced back here, both unhandled rejections escaping the async `seriesChain`. `SpeechSynthesisUtterance is not defined` (webOS / Chrome 53). webOS ships no Web Speech API, so evaluating the bare global inside `phrase instanceof SpeechSynthesisUtterance` throws a ReferenceError — and it does so even in aria mode, which never intends to speak, because the check sits in the else-if chain ahead of the function and array branches. Guard it with `typeof`. `window.speechSynthesis` gets the same treatment: the DOM lib types it as always present but it is undefined there, and `synth.cancel()` is reached synchronously from the default export on every new announcement — so an unguarded call threw straight into app code, outside any promise chain. `Speech synthesis error: synthesis-failed` / `not-allowed` (Xumo, Tizen). `handleSpeechError` rethrew every code outside network/canceled/ interrupted. Those codes are the platform's TTS engine declining to speak, not programming errors, and rethrowing aborted the rest of the series as well as surfacing in the host app. They now log and stop retrying. The `network` backoff-and-retry and the silent canceled/interrupted handling are unchanged. `seriesChain` finally catches as a backstop: `SeriesResult.series` is handed to callers who typically never await it, so anything escaping it lands as an unhandledrejection no matter what threw. 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.
Two uncaught exceptions in a shipped TV app traced back to
src/primitives/announcer/speech.ts. Both are unhandled rejections escaping the asyncseriesChain, and both reach the host app as runtime crashes rather than silent TTS failures.Found by grouping uncaught JS exceptions (
@origin:source) by message across a real TV fleet — webOS (Chrome 53), Tizen, Vizio and Xumo (Safari).1.
SpeechSynthesisUtterance is not defined— webOSwebOS ships no Web Speech API at all, so it declares neither
window.speechSynthesisnor theSpeechSynthesisUtteranceconstructor. Merely evaluating the bare global throws aReferenceError, andspeakSeriesdid exactly that:This fires even in aria mode — where
speak()is never called and speech synthesis is never touched — because theinstanceofis not gated onaria. Worth noting it does not fire on every announcement: the check sits in the else-if chain ahead of the function and nested-array branches, so a plain string phrase short-circuits earlier and never evaluates the global. Only a series containing a function or array phrase reaches it, which is why this shows up as a steady trickle rather than a total webOS outage.Fixed with a
typeoftype guard.window.speechSynthesisgets the same treatment — the DOM lib types it as always present, so it is now typedSpeechSynthesis | undefinedlocally to force the check.synth.cancel()was the worse hazard. It is reached synchronously from the default export (currentSeries?.cancel()runs on every new announcement) and fromAnnouncer.cancel(), so on a device with nospeechSynthesisit threw aTypeErrorstraight into app code — outside any promise chain, where theseriesChainbackstop below could never have caught it. Now guarded.The
SpeechSynthesisUtterancein theCoreSpeechTypeunion is deliberately left alone: it is a type-only position, erased bytsc, so it cannot throw, and removing it would be a gratuitous breaking change for platforms that do have the API.2.
Speech synthesis error: synthesis-failed/not-allowed— Xumo, TizenhandleSpeechErrorrethrew every code outsidenetwork/canceled/interrupted. Butsynthesis-failed,not-allowedand an error carrying no code at all are the platform's TTS engine declining to speak — not programming errors. Rethrowing aborted the rest of the series and surfaced as an uncaught exception in the host app.They now log and stop retrying (a refused engine will not change its mind on the next attempt the way a
networkblip might).networkbackoff-and-retry and the silentcanceled/interruptedhandling are unchanged — those are deliberate and covered by preservation tests.3. Backstop
seriesChainnow catches.SeriesResult.seriesis handed to callers who typically never await it, so anything escaping it lands as anunhandledrejectionregardless of cause — including a caller-supplied function phrase throwing, or a future error code nobody has seen yet. A failed announcement must never crash the host app.Browser compatibility
Everything added is
typeof,instanceof, a template literal,||andPromise.prototype.catch. No spread, no new Array/Object methods, and specifically noPromise.finally(Safari 11.1+). The type predicate andSpeechSynthesis | undefinedannotations are erased at compile time. Safe on Chrome 53 and Safari 11.Tests
New
tests/announcer-speech-errors.spec.ts(Vitest + jsdom, followingannouncer-aria.spec.tsconventions). jsdom declares neither Web Speech global, so it models webOS exactly with no setup; a fake utterance and synth are installed per-test for the error-classification cases and deleted inafterEach— necessary, since this repo runsisolate: falseand a leaked global would follow into other spec files.Negative control — reverting the fix and re-running the new spec fails 7 of 12, with the first failure reproducing the production error verbatim (
ReferenceError: SpeechSynthesisUtterance is not defined). The 5 that pass either way are the preservation tests.Out of scope, but worth flagging
src/primitives/announcer/announcer.ts:169callscurrentlySpeaking?.series.finally(...)on thenotificationpath.Promise.prototype.finallylanded in Safari 11.1, so this is aTypeErroron Safari 11.0 and below — the same class of bug as this PR, in a different file. Left alone here rather than widening the change.🤖 Generated with Claude Code