mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix: guard subtitle prefetch init races
This commit is contained in:
114
src/main/runtime/subtitle-prefetch-init.test.ts
Normal file
114
src/main/runtime/subtitle-prefetch-init.test.ts
Normal 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, []);
|
||||
});
|
||||
83
src/main/runtime/subtitle-prefetch-init.ts
Normal file
83
src/main/runtime/subtitle-prefetch-init.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user