Rehoboth Builds

A timeout watchdog will fire while the user has it paused

We shipped a timer that watches for a speech engine that accepts a request and then says nothing. Its test is "no start event by now." Pausing produces exactly that state, legitimately — so the timer fires, concludes the engine is broken, and starts undoing the user's settings.

8 September 2026 · found by reading our own code, not in the wild

1. Why the timer exists at all

A text-to-speech engine can accept a request and then produce nothing: no audio, no end, and no error either. We measured one that emitted its first start 23.5 seconds after the call and never emitted end at all; that measurement and the rest of the silent-failure family are written up separately. The API has no event for "the engine is not answering," so the only way to notice is to start a timer yourself and check whether the first start arrived.

Ours waits seven seconds. If nothing has started by then, it assumes the selected voice is the problem, clears it, and retries on the system default — a deliberate self-heal, because otherwise a user who once picked a bad voice waits seven seconds on every single page, forever.

2. Pause makes the predicate true without making it correct

The timer is not testing "the engine is broken." It is testing "no start event has arrived," and treating that as a proxy. Pause breaks the proxy: while paused, the absence of a start is not evidence of anything, because we are the ones who stopped it.

The damage is not that pause looks flaky. The recovery path runs in full: it writes an empty voice into stored settings — silently discarding the voice the user chose — and then calls speak again. The user pressed pause and got sound. On the branch where no voice was selected, the recovery instead stops everything, so the state is idle by the time they press resume, and resume does nothing at all.

This is reachable, not theoretical. The engine that took 23.5 seconds to start is exactly the situation where a user reaches for pause — and the timer only waits seven.

3. Disarming on pause is half a fix

The obvious repair is to cancel the timer in pause(). That is necessary and not sufficient, for two reasons we had to handle separately.

First, a timer that has already been queued still runs. If the callback was scheduled in the same tick that pause landed, cancelling the handle does not un-schedule what is already on its way, so the callback itself has to re-check the state and return when the status is paused. A cancel and a guard are not redundant here; they cover different moments.

Second — and this is the part that turns one bug into another — if you only disarm, the rest of that reading has no watchdog. The user resumes, the engine is still unresponsive, and now nothing is watching. Resume has to re-arm the timer with the same parameters it had before the pause, which means keeping those parameters around rather than letting them live only inside the timer's closure.

4. The general shape

A watchdog is a claim about a system that is supposed to be making progress. Every control that legitimately suspends progress — pause, backgrounding, an offline window, a deliberate user stop — falsifies that claim while it lasts, and the watchdog has no way to tell "suspended on purpose" from "wedged" unless you tell it.

The reason this one was worth fixing rather than tolerating is what the recovery does. A watchdog whose response is to log or retry can afford a false positive. Ours mutates the user's saved settings and produces audio. The blast radius of a false positive is what decides how carefully you have to define the predicate — not how likely the false positive is.

How we know

Honestly: this one came from reading our own code, not from a user report or a recording. The measured part is upstream of it — the 23.5-second first start is something we timed on one machine, and it is what makes the race reachable rather than academic.

The fix ships with assertions over the background logic, and we checked they were doing work by breaking them on purpose: reverting to the pre-fix pause turns one specific test red, and disarming without re-arming on resume turns a different one red. Green tests that stay green when you break the thing they cover are not evidence.

We are not claiming anything about how other browsers or the Web Speech API behave here. We did not measure those.

Where this came from

It came out of Highlight Reader, a Chrome extension that reads a page aloud and highlights each sentence as it speaks. It is free on the Chrome Web Store, needs no account, and uses the voices the browser already ships. A paid add-on — word-level highlighting and higher-quality voices — is described here, and it is an add-on, not the extension.