fix(tts): avoid race condition in preloadNextSSML causing wrong highlights (#3748)

preloadNextSSML() called tts.next() interleaved with await, creating async
gaps where the concurrent #speak() could dispatch marks against corrupted
#ranges state (replaced by next() for a different block). Since mark names
restart at "0" per block, marks resolved to the wrong text position, causing
accidental page turns and highlights far from the current sentence.

Fix: gather all tts.next() and tts.prev() calls synchronously (no await
between them) so no async code can see the intermediate #ranges state.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-04-05 02:42:06 +08:00
committed by GitHub
parent 52242d6886
commit 21795e5cd3
2 changed files with 67 additions and 5 deletions
@@ -261,15 +261,27 @@ export class TTSController extends EventTarget {
const tts = this.view.tts;
if (!tts) return;
const ssmls: string[] = [];
// Gather all next SSMLs and rewind synchronously to avoid a race condition:
// tts.next() replaces TTS.#ranges (used by setMark() during playback).
// If async gaps exist between next()/prev() calls, a concurrent #speak()
// can dispatch marks against the wrong #ranges, causing incorrect highlights
// and accidental page turns.
const rawSsmls: string[] = [];
for (let i = 0; i < count; i++) {
const ssml = await this.#preprocessSSML(tts.next());
const ssml = tts.next();
if (!ssml) break;
rawSsmls.push(ssml);
}
for (let i = 0; i < rawSsmls.length; i++) {
tts.prev();
}
const ssmls: string[] = [];
for (const raw of rawSsmls) {
const ssml = await this.#preprocessSSML(raw);
if (!ssml) break;
ssmls.push(ssml);
}
for (let i = 0; i < ssmls.length; i++) {
tts.prev();
}
await Promise.all(ssmls.map((ssml) => this.preloadSSML(ssml, new AbortController().signal)));
}