Skip to content

fix(announcer): never let a TTS failure escape as an uncaught exception - #60

Merged
chiefcll merged 1 commit into
mainfrom
fix/tts-uncaught-exceptions
Sep 11, 2026
Merged

chiefcll merged 1 commit into
mainfrom
fix/tts-uncaught-exceptions

Conversation

@chiefcll

Copy link
Copy Markdown
Contributor

Two uncaught exceptions in a shipped TV app traced back to src/primitives/announcer/speech.ts. Both are unhandled rejections escaping the async seriesChain, 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 — webOS

webOS ships no Web Speech API at all, so it declares neither window.speechSynthesis nor the SpeechSynthesisUtterance constructor. Merely evaluating the bare global throws a ReferenceError, and speakSeries did exactly that:

} else if (phrase instanceof SpeechSynthesisUtterance) {

This fires even in aria mode — where speak() is never called and speech synthesis is never touched — because the instanceof is not gated on aria. 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 typeof type guard. window.speechSynthesis gets the same treatment — the DOM lib types it as always present, so it is now typed SpeechSynthesis | undefined locally 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 from Announcer.cancel(), so on a device with no speechSynthesis it threw a TypeError straight into app code — outside any promise chain, where the seriesChain backstop below could never have caught it. Now guarded.

The SpeechSynthesisUtterance in the CoreSpeechType union is deliberately left alone: it is a type-only position, erased by tsc, 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, Tizen

handleSpeechError rethrew every code outside network / canceled / interrupted. But synthesis-failed, not-allowed and 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 network blip might). network backoff-and-retry and the silent canceled/interrupted handling are unchanged — those are deliberate and covered by preservation tests.

3. Backstop

seriesChain now catches. SeriesResult.series is handed to callers who typically never await it, so anything escaping it lands as an unhandledrejection regardless 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, || and Promise.prototype.catch. No spread, no new Array/Object methods, and specifically no Promise.finally (Safari 11.1+). The type predicate and SpeechSynthesis | undefined annotations are erased at compile time. Safe on Chrome 53 and Safari 11.

Tests

New tests/announcer-speech-errors.spec.ts (Vitest + jsdom, following announcer-aria.spec.ts conventions). 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 in afterEach — necessary, since this repo runs isolate: false and 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.

$ pnpm run tsc                 → exit 0
$ npx prettier --check         → all files use Prettier style
$ npx eslint .                 → 0 errors (154 pre-existing warnings elsewhere in src/)
$ npx vitest run               → 18 files, 186 tests passed

Out of scope, but worth flagging

src/primitives/announcer/announcer.ts:169 calls currentlySpeaking?.series.finally(...) on the notification path. Promise.prototype.finally landed in Safari 11.1, so this is a TypeError on 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

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>
@chiefcll
chiefcll merged commit f737a70 into main Sep 11, 2026
1 check passed
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