fix: guard subtitle prefetch init races

This commit is contained in:
2026-03-15 14:42:36 -07:00
parent 87bf3cef0c
commit d069df2124
6 changed files with 234 additions and 37 deletions

View File

@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch';
import { createSubtitlePrefetchInitController } from './subtitle-prefetch-init';
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function flushMicrotasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
test('latest subtitle prefetch init wins over stale async loads', async () => {
const loads = new Map<string, ReturnType<typeof createDeferred<string>>>();
const started: string[] = [];
const stopped: string[] = [];
let currentService: SubtitlePrefetchService | null = null;
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => currentService,
setCurrentService: (service) => {
currentService = service;
},
loadSubtitleSourceText: async (source) => {
const deferred = createDeferred<string>();
loads.set(source, deferred);
return await deferred.promise;
},
parseSubtitleCues: (_content, filename): SubtitleCue[] => [
{ startTime: 0, endTime: 1, text: filename },
],
createSubtitlePrefetchService: ({ cues }) => ({
start: () => {
started.push(cues[0]!.text);
},
stop: () => {
stopped.push(cues[0]!.text);
},
onSeek: () => {},
pause: () => {},
resume: () => {},
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
});
const firstInit = controller.initSubtitlePrefetch('old.ass', 1);
const secondInit = controller.initSubtitlePrefetch('new.ass', 2);
loads.get('new.ass')!.resolve('new');
await flushMicrotasks();
assert.deepEqual(started, ['new.ass']);
loads.get('old.ass')!.resolve('old');
await Promise.all([firstInit, secondInit]);
assert.deepEqual(started, ['new.ass']);
assert.deepEqual(stopped, []);
});
test('cancelPendingInit prevents an in-flight load from attaching a stale service', async () => {
const deferred = createDeferred<string>();
let currentService: SubtitlePrefetchService | null = null;
const started: string[] = [];
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => currentService,
setCurrentService: (service) => {
currentService = service;
},
loadSubtitleSourceText: async () => await deferred.promise,
parseSubtitleCues: (_content, filename): SubtitleCue[] => [
{ startTime: 0, endTime: 1, text: filename },
],
createSubtitlePrefetchService: ({ cues }) => ({
start: () => {
started.push(cues[0]!.text);
},
stop: () => {},
onSeek: () => {},
pause: () => {},
resume: () => {},
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
});
const initPromise = controller.initSubtitlePrefetch('stale.ass', 1);
controller.cancelPendingInit();
deferred.resolve('stale');
await initPromise;
assert.equal(currentService, null);
assert.deepEqual(started, []);
});

View File

@@ -0,0 +1,83 @@
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
import type {
SubtitlePrefetchService,
SubtitlePrefetchServiceDeps,
} from '../../core/services/subtitle-prefetch';
import type { SubtitleData } from '../../types';
export interface SubtitlePrefetchInitControllerDeps {
getCurrentService: () => SubtitlePrefetchService | null;
setCurrentService: (service: SubtitlePrefetchService | null) => void;
loadSubtitleSourceText: (source: string) => Promise<string>;
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService;
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void;
isCacheFull: () => boolean;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
}
export interface SubtitlePrefetchInitController {
cancelPendingInit: () => void;
initSubtitlePrefetch: (externalFilename: string, currentTimePos: number) => Promise<void>;
}
export function createSubtitlePrefetchInitController(
deps: SubtitlePrefetchInitControllerDeps,
): SubtitlePrefetchInitController {
let initRevision = 0;
const cancelPendingInit = (): void => {
initRevision += 1;
deps.getCurrentService()?.stop();
deps.setCurrentService(null);
};
const initSubtitlePrefetch = async (
externalFilename: string,
currentTimePos: number,
): Promise<void> => {
const revision = ++initRevision;
deps.getCurrentService()?.stop();
deps.setCurrentService(null);
try {
const content = await deps.loadSubtitleSourceText(externalFilename);
if (revision !== initRevision) {
return;
}
const cues = deps.parseSubtitleCues(content, externalFilename);
if (revision !== initRevision || cues.length === 0) {
return;
}
const nextService = deps.createSubtitlePrefetchService({
cues,
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
isCacheFull: () => deps.isCacheFull(),
});
if (revision !== initRevision) {
return;
}
deps.setCurrentService(nextService);
nextService.start(currentTimePos);
deps.logInfo(
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`,
);
} catch (error) {
if (revision === initRevision) {
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
}
}
};
return {
cancelPendingInit,
initSubtitlePrefetch,
};
}

View File

@@ -39,3 +39,9 @@ test('resolveSubtitleSourcePath converts file URLs with spaces into filesystem p
test('resolveSubtitleSourcePath leaves non-file sources unchanged', () => {
assert.equal(resolveSubtitleSourcePath('/tmp/subs.ass'), '/tmp/subs.ass');
});
test('resolveSubtitleSourcePath returns the original source for malformed file URLs', () => {
const source = 'file://invalid[path';
assert.equal(resolveSubtitleSourcePath(source), source);
});

View File

@@ -30,5 +30,13 @@ export function getActiveExternalSubtitleSource(
}
export function resolveSubtitleSourcePath(source: string): string {
return source.startsWith('file://') ? fileURLToPath(new URL(source)) : source;
if (!source.startsWith('file://')) {
return source;
}
try {
return fileURLToPath(new URL(source));
} catch {
return source;
}
}