From 70e4b1c76b696f2ccfe964ba69e19046d252ceb1 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Fri, 11 Sep 2026 12:38:51 -0400 Subject: [PATCH] fix(announcer): never let a TTS failure escape as an uncaught exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/primitives/announcer/speech.ts | 67 +++++++- tests/announcer-speech-errors.spec.ts | 218 ++++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 tests/announcer-speech-errors.spec.ts diff --git a/src/primitives/announcer/speech.ts b/src/primitives/announcer/speech.ts index b4ff14d9..e9987db1 100644 --- a/src/primitives/announcer/speech.ts +++ b/src/primitives/announcer/speech.ts @@ -26,6 +26,24 @@ interface SpeechError extends Error { error?: string; } +// The same caveat applies to the rest of the Web Speech API, and it is not just +// a typing nicety: webOS (Chrome 53) 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 — +// including inside an `instanceof` on a code path that never intends to speak, +// which is how a webOS app running in aria mode (Announcer.aria = true, speech +// synthesis never touched) still managed to crash. `typeof` is the only safe +// probe for a possibly-undeclared global, so every use of the API in this file +// goes through this guard or through a `SpeechSynthesis | undefined` local. +function isSpeechSynthesisUtterance( + phrase: unknown, +): phrase is SpeechSynthesisUtterance { + return ( + typeof SpeechSynthesisUtterance !== 'undefined' && + phrase instanceof SpeechSynthesisUtterance + ); +} + function flattenStrings(series: SpeechType[] = []): SpeechType[] { const flattenedSeries = []; @@ -142,9 +160,20 @@ function speak( lang = 'en-US', voiceName?: string, ) { - const synth = window.speechSynthesis; + // TypeScript's DOM lib types `window.speechSynthesis` as always present, but + // on a TV browser without the Web Speech API it is `undefined` at runtime. + // Type it honestly so the compiler forces the guard below. + const synth: SpeechSynthesis | undefined = window.speechSynthesis; return new Promise((resolve, reject) => { + if (!synth || typeof SpeechSynthesisUtterance === 'undefined') { + // No speech engine on this device. Resolve rather than reject so the rest + // of the series still runs and the app degrades to silence instead of + // throwing a ReferenceError out of the chain. + resolve(); + return; + } + let selectedVoice; if (voiceName) { const availableVoices = synth.getVoices(); @@ -180,7 +209,8 @@ function speak( * benign — a newer announcement cancelled or replaced the in-flight one (see * synth.cancel()), which happens constantly during directional navigation — so * we stop retrying without surfacing them. `network` errors back off and retry. - * Anything else is genuinely unexpected and is rethrown. + * Anything else is a device-level failure of the speech engine: logged, not + * rethrown. */ async function handleSpeechError( e: unknown, @@ -202,7 +232,16 @@ async function handleSpeechError( return 0; // benign — stop retrying, don't propagate } - throw e; + // Everything else — "synthesis-failed", "not-allowed", or an error carrying no + // code at all — is the platform's TTS engine declining to speak, not a + // programming error. Xumo (Safari 11) and Tizen both report these routinely + // when the system voice is unavailable or refuses to start unprompted. + // Rethrowing used to abort the whole remaining series *and* surface as an + // uncaught exception in the host app, so instead we log it, keep the rest of + // the phrases going, and stop retrying (a refused engine won't change its + // mind on the next attempt the way a `network` blip might). + console.warn(`Speech synthesis failed: ${code || 'no error code'}`); + return 0; } function speakSeries( @@ -212,7 +251,8 @@ function speakSeries( voice?: string, root = true, ): SeriesResult { - const synth = window.speechSynthesis; + // Possibly-undefined on purpose — see isSpeechSynthesisUtterance above. + const synth: SpeechSynthesis | undefined = window.speechSynthesis; const remainingPhrases = flattenStrings( Array.isArray(series) ? series : [series], ); @@ -255,7 +295,7 @@ function speakSeries( ); } } - } else if (phrase instanceof SpeechSynthesisUtterance) { + } else if (isSpeechSynthesisUtterance(phrase)) { // Handle SpeechSynthesisUtterance objects with retry logic const totalRetries = 3; let retriesLeft = totalRetries; @@ -298,7 +338,16 @@ function speakSeries( focusElementForAria(); } } - })(); + })().catch((e) => { + // Last line of defence, and the reason the fixes above are not enough on + // their own: `SeriesResult.series` is handed to callers who usually never + // await it, so anything escaping this chain lands as an `unhandledrejection` + // and is reported as an uncaught exception in the host app. A failed + // announcement must never do that — whatever the cause (a caller-supplied + // function phrase throwing, a future synthesis error code), log it and let + // the promise resolve. + console.warn('Speech series failed:', e); + }); return { series: seriesChain, @@ -320,7 +369,11 @@ function speakSeries( // screen reader can finish. Just drop any partially accumulated // phrases from this canceled series. ariaLabelPhrases = []; - } else { + } else if (synth) { + // Undefined on TV browsers without the Web Speech API. cancel() runs + // synchronously in the caller (see the default export, which cancels + // the previous series before starting a new one), so an unguarded + // call here would throw straight into app code. synth.cancel(); // Cancel all ongoing speech } } diff --git a/tests/announcer-speech-errors.spec.ts b/tests/announcer-speech-errors.spec.ts new file mode 100644 index 00000000..ac05fc05 --- /dev/null +++ b/tests/announcer-speech-errors.spec.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import speak from '../src/primitives/announcer/speech.ts'; + +// These tests cover the two production crash classes reported from real TV +// hardware: +// +// A. webOS (Chrome 53) ships no Web Speech API at all, so touching the bare +// `SpeechSynthesisUtterance` global throws a ReferenceError — even on the +// aria code path that never intends to speak. jsdom happens to model this +// platform exactly: it declares neither global, so the "absent API" tests +// below need no setup. +// B. Xumo (Safari 11) and Tizen routinely report device-level synthesis +// failures ("synthesis-failed", "not-allowed", or no code at all). These +// must not escape as unhandled rejections, because callers hold +// `SeriesResult.series` without awaiting it. + +const ARIA_PARENT_ID = 'aria-parent'; + +function ariaLabels(): string[] { + const parent = document.getElementById(ARIA_PARENT_ID); + if (!parent) return []; + return Array.from(parent.querySelectorAll('span')).map( + (span) => span.getAttribute('aria-label') ?? '', + ); +} + +type FakeErrorEvent = { error?: string }; + +class FakeUtterance { + text: string; + lang = ''; + voice: unknown = null; + onend: (() => void) | null = null; + onerror: ((e: FakeErrorEvent) => void) | null = null; + + constructor(text: string) { + this.text = text; + } +} + +/** + * Stand in for a platform that *does* expose the Web Speech API. `onSpeak` + * decides how the fake engine responds; it receives the utterance and the + * 1-based attempt number so retry behaviour can be asserted. + */ +function installSpeechEngine( + onSpeak: (utterance: FakeUtterance, attempt: number) => void, +): FakeUtterance[] { + const spoken: FakeUtterance[] = []; + + const synth = { + speak(utterance: FakeUtterance) { + spoken.push(utterance); + onSpeak(utterance, spoken.length); + }, + cancel() {}, + getVoices() { + return []; + }, + }; + + (globalThis as unknown as Record).SpeechSynthesisUtterance = + FakeUtterance; + (window as unknown as Record).speechSynthesis = synth; + + return spoken; +} + +function failWith(code: string | undefined) { + return (utterance: FakeUtterance) => { + if (utterance.onerror) utterance.onerror({ error: code }); + }; +} + +afterEach(() => { + // vitest runs with `isolate: false`, so a leaked global would follow us into + // every other spec file. Put the environment back to a bare webOS-like one. + delete (globalThis as unknown as Record) + .SpeechSynthesisUtterance; + delete (window as unknown as Record).speechSynthesis; + document.getElementById(ARIA_PARENT_ID)?.remove(); + vi.restoreAllMocks(); +}); + +describe('Announcer on a platform without the Web Speech API', () => { + it('has no Web Speech globals to begin with (the webOS case)', () => { + expect( + (globalThis as unknown as Record) + .SpeechSynthesisUtterance, + ).toBeUndefined(); + expect( + (window as unknown as Record).speechSynthesis, + ).toBeUndefined(); + }); + + it('runs an aria series containing a non-string phrase to completion', async () => { + // A function or nested-array phrase reaches the `instanceof + // SpeechSynthesisUtterance` branch of the dispatch chain. Evaluating that + // bare global threw a ReferenceError on webOS, taking down the whole + // announcement even though aria mode never speaks. + const result = speak([() => ['Nested label'], 'Tail label'], true); + + await expect(result.series).resolves.toBeUndefined(); + expect(ariaLabels()).toEqual(['Nested label', 'Tail label']); + }); + + it('runs a non-aria series to completion, degrading to silence', async () => { + const result = speak(['No engine here'], false); + + await expect(result.series).resolves.toBeUndefined(); + }); + + it('cancels the previous series without throwing into app code', async () => { + const first = speak(['One'], false); + + // The default export cancels the in-flight series synchronously, which hits + // `synth.cancel()` on a `window.speechSynthesis` that does not exist. + expect(() => speak(['Two'], false)).not.toThrow(); + + await expect(first.series).resolves.toBeUndefined(); + }); +}); + +describe('Announcer speech error classification', () => { + it.each(['synthesis-failed', 'not-allowed', undefined])( + 'treats a %s error as benign instead of rejecting', + async (code) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const spoken = installSpeechEngine(failWith(code)); + + const result = speak(['Hello there'], false); + + await expect(result.series).resolves.toBeUndefined(); + // A refused engine will not change its mind, so no retries. + expect(spoken.length).toBe(1); + expect(warn).toHaveBeenCalledTimes(1); + }, + ); + + it.each(['canceled', 'interrupted'])( + 'treats a %s error as benign and silent', + async (code) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const spoken = installSpeechEngine(failWith(code)); + + const result = speak(['Interrupted phrase'], false); + + await expect(result.series).resolves.toBeUndefined(); + expect(spoken.length).toBe(1); + expect(warn).not.toHaveBeenCalled(); + }, + ); + + it('retries a network error with backoff, then stops', async () => { + vi.useFakeTimers(); + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const spoken = installSpeechEngine(failWith('network')); + + let settled = 'pending'; + const result = speak(['Flaky phrase'], false); + void result.series.then( + () => { + settled = 'resolved'; + }, + () => { + settled = 'rejected'; + }, + ); + + // Backoff is 500ms, 1000ms, 1500ms across the three attempts. + await vi.advanceTimersByTimeAsync(4000); + + expect(spoken.length).toBe(3); + expect(settled).toBe('resolved'); + expect(warn).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps speaking the rest of the series after a failed phrase', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const spoken = installSpeechEngine((utterance, attempt) => { + if (attempt === 1) { + failWith('synthesis-failed')(utterance); + } else if (utterance.onend) { + utterance.onend(); + } + }); + + const result = speak([() => 'First', () => 'Second'], false); + + await expect(result.series).resolves.toBeUndefined(); + expect(spoken.map((utterance) => utterance.text)).toEqual([ + 'First', + 'Second', + ]); + }); + + it('still speaks SpeechSynthesisUtterance phrases where the API exists', async () => { + const spoken = installSpeechEngine((utterance) => { + if (utterance.onend) utterance.onend(); + }); + + const utterance = new FakeUtterance('From utterance'); + utterance.lang = 'en-GB'; + + const result = speak( + [utterance as unknown as SpeechSynthesisUtterance], + false, + ); + + await expect(result.series).resolves.toBeUndefined(); + expect(spoken.map((u) => u.text)).toEqual(['From utterance']); + expect(spoken[0]!.lang).toBe('en-GB'); + }); +});