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
@@ -635,6 +635,56 @@ describe('TTSController', () => {
});
});
describe('preloadNextSSML', () => {
test('calls tts.next() and tts.prev() synchronously without async gaps between them', async () => {
// This test verifies the fix for a race condition where async gaps between
// tts.next() calls in preloadNextSSML allowed #speak() to interleave and
// read corrupted #ranges state (replaced by next() for a different block).
const callOrder: string[] = [];
let asyncOpHappened = false;
mockView.tts = {
next: vi.fn().mockImplementation(() => {
if (asyncOpHappened) {
callOrder.push('next-after-async');
} else {
callOrder.push('next');
}
return '<speak>chunk</speak>';
}),
prev: vi.fn().mockImplementation(() => {
callOrder.push('prev');
}),
doc: {},
} as unknown as FoliateView['tts'];
// Use preprocessCallback to detect when async processing happens
controller.preprocessCallback = async (ssml: string) => {
asyncOpHappened = true;
callOrder.push('preprocess');
return ssml;
};
await controller.preloadNextSSML(2);
// All next() calls should happen before any preprocess (async operation)
const firstPreprocessIdx = callOrder.indexOf('preprocess');
const nextIndices = callOrder.map((op, i) => (op === 'next' ? i : -1)).filter((i) => i >= 0);
const prevIndices = callOrder.map((op, i) => (op === 'prev' ? i : -1)).filter((i) => i >= 0);
// All next() calls must come before any async preprocessing
for (const idx of nextIndices) {
expect(idx).toBeLessThan(firstPreprocessIdx);
}
// All prev() calls must come before any async preprocessing
for (const idx of prevIndices) {
expect(idx).toBeLessThan(firstPreprocessIdx);
}
// No next() should happen after an async operation
expect(callOrder).not.toContain('next-after-async');
});
});
describe('initViewTTS', () => {
test('does nothing when already initialised (section index != -1)', async () => {
// Manually set section index via a reflect access workaround
@@ -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)));
}