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:
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user