From 21795e5cd3fd8f2a1dd9db899fafe3d0900df543 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 5 Apr 2026 02:42:06 +0800 Subject: [PATCH] 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) --- .../__tests__/services/tts-controller.test.ts | 50 +++++++++++++++++++ .../src/services/tts/TTSController.ts | 22 ++++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/apps/readest-app/src/__tests__/services/tts-controller.test.ts b/apps/readest-app/src/__tests__/services/tts-controller.test.ts index 0d982ecd..1536dd5b 100644 --- a/apps/readest-app/src/__tests__/services/tts-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/tts-controller.test.ts @@ -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 'chunk'; + }), + 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 diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index c084684b..22cf5dca 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -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))); }