fix: reuse background tokenization warmups

This commit is contained in:
2026-03-08 16:01:11 -07:00
parent f775f90360
commit 38034db1e4
5 changed files with 334 additions and 33 deletions

View File

@@ -0,0 +1,63 @@
---
id: TASK-131
title: Avoid duplicate tokenization warmup after background startup
status: Done
assignee:
- codex
created_date: '2026-03-08 10:12'
updated_date: '2026-03-08 12:00'
labels:
- bug
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/composers/mpv-runtime-composer.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/startup-warmups.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/composers/mpv-runtime-composer.test.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When SubMiner is already running in the background and mpv is launched from the launcher or mpv plugin, the live app should reuse startup tokenization warmup state instead of re-entering the Yomitan/tokenization/annotation warmup path on first overlay use.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Background startup tokenization warmup is recorded in the runtime state used by later mpv/tokenization flows.
- [x] #2 Launching mpv from the launcher or plugin against an already-running background app does not re-run duplicate Yomitan/tokenization annotation warmup work in the live process.
- [x] #3 Regression tests cover the warmed-background path and protect against re-entering duplicate warmup work.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a regression test covering the case where background startup warmups already completed and a later tokenize call must not re-enter Yomitan/MeCab/dictionary warmups.
2. Update mpv tokenization warmup composition so startup background warmups and on-demand tokenization share the same completion state.
3. Run the focused composer/runtime tests and update acceptance criteria/notes with results.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root-cause hypothesis: startup background warmups and on-demand tokenization warmups use separate state, so later mpv launch can re-enter warmup bookkeeping even though background startup already warmed dependencies.
Implemented shared warmup state between startup background warmups and on-demand tokenization warmups by forwarding scheduled Yomitan/tokenization promises into the mpv runtime composer. Added regression coverage for the warmed-background path. Verified with `bun run test:fast` plus focused composer/startup warmup tests.
Follow-up root cause from live retest: second mpv open could still pause on the startup gate because the runtime only treated full background tokenization warmup completion as reusable readiness. In practice, first-file tokenization could already be ready while slower dictionary prewarm work was still finishing, so reopening a video waited on duplicate warmup completion even though annotations were already usable.
Adjusted `src/main/runtime/composers/mpv-runtime-composer.ts` so autoplay reuse keys off a separate playback-ready latch. The latch flips true either when background warmups fully cover tokenization or when `onTokenizationReady` fires for a real subtitle line. `src/main.ts` already uses `isTokenizationWarmupReady()` to fast-signal `subminer-autoplay-ready` on a fresh media-path change, so reopened videos can now resume immediately once tokenization has succeeded once in the persistent app.
Validation update: `bun test src/core/services/cli-command.test.ts src/main/runtime/mpv-main-event-actions.test.ts src/main/runtime/composers/mpv-runtime-composer.test.ts launcher/mpv.test.ts launcher/smoke.e2e.test.ts` passed, `lua scripts/test-plugin-start-gate.lua` passed, and `bun run typecheck` passed.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Background startup tokenization warmups now feed the same in-memory warmup state used by later mpv tokenization. When the app is already running and warmed in the background, launcher/plugin-driven mpv startup reuses that state instead of re-entering Yomitan/tokenization annotation warmups. Added a regression test for the warmed-background path and verified with `bun run test:fast`.
A later follow-up fixed the remaining second-open delay: autoplay reuse no longer waits for the entire background dictionary warmup pipeline to finish. After the persistent app has produced one tokenization-ready event, later mpv reconnects reuse that readiness immediately, so reopening the same or another video does not pause again on duplicate warmup bookkeeping.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -212,6 +212,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
assert.equal(typeof composed.createMecabTokenizerAndCheck, 'function');
assert.equal(typeof composed.prewarmSubtitleDictionaries, 'function');
assert.equal(typeof composed.startTokenizationWarmups, 'function');
assert.equal(typeof composed.isTokenizationWarmupReady, 'function');
assert.equal(typeof composed.launchBackgroundWarmupTask, 'function');
assert.equal(typeof composed.startBackgroundWarmups, 'function');
@@ -219,7 +220,9 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
assert.equal(client.connected, true);
composed.updateMpvSubtitleRenderMetrics({ subPos: 90 });
assert.equal(composed.isTokenizationWarmupReady(), false);
await composed.startTokenizationWarmups();
assert.equal(composed.isTokenizationWarmupReady(), true);
const tokenized = await composed.tokenizeSubtitle('subtitle text');
await composed.createMecabTokenizerAndCheck();
await composed.prewarmSubtitleDictionaries();
@@ -789,9 +792,11 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
const warmupPromise = composed.startTokenizationWarmups();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(osdMessages, []);
assert.equal(composed.isTokenizationWarmupReady(), false);
await composed.tokenizeSubtitle('first line');
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
assert.equal(composed.isTokenizationWarmupReady(), true);
jlptDeferred.resolve();
frequencyDeferred.resolve();
@@ -800,3 +805,154 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']);
});
test('composeMpvRuntimeHandlers reuses completed background tokenization warmups for later tokenize calls', async () => {
let started = false;
let yomitanWarmupCalls = 0;
let mecabWarmupCalls = 0;
let jlptWarmupCalls = 0;
let frequencyWarmupCalls = 0;
let mecabTokenizer: { tokenize: () => Promise<never[]> } | null = null;
const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void },
{ isKnownWord: () => boolean },
{ text: string }
>({
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => true,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => mecabTokenizer,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => mecabTokenizer,
setMecabTokenizer: (next) => {
mecabTokenizer = next as { tokenize: () => Promise<never[]> };
},
createMecabTokenizer: () => ({ tokenize: async () => [] }),
checkAvailability: async () => {
mecabWarmupCalls += 1;
},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {
jlptWarmupCalls += 1;
},
ensureFrequencyDictionaryLookup: async () => {
frequencyWarmupCalls += 1;
},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => started,
setStarted: (next) => {
started = next;
},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {
yomitanWarmupCalls += 1;
},
shouldWarmupMecab: () => true,
shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => true,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
composed.startBackgroundWarmups();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(yomitanWarmupCalls, 1);
assert.equal(mecabWarmupCalls, 1);
assert.equal(jlptWarmupCalls, 1);
assert.equal(frequencyWarmupCalls, 1);
await composed.tokenizeSubtitle('first line after background warmup');
assert.equal(yomitanWarmupCalls, 1);
assert.equal(mecabWarmupCalls, 1);
assert.equal(jlptWarmupCalls, 1);
assert.equal(frequencyWarmupCalls, 1);
});

View File

@@ -88,6 +88,7 @@ export type MpvRuntimeComposerResult<
createMecabTokenizerAndCheck: () => Promise<void>;
prewarmSubtitleDictionaries: () => Promise<void>;
startTokenizationWarmups: () => Promise<void>;
isTokenizationWarmupReady: () => boolean;
launchBackgroundWarmupTask: ReturnType<typeof createLaunchBackgroundWarmupTaskFromStartup>;
startBackgroundWarmups: ReturnType<typeof createStartBackgroundWarmupsFromStartup>;
}>;
@@ -151,6 +152,36 @@ export function composeMpvRuntimeHandlers<
let tokenizationPrerequisiteWarmupInFlight: Promise<void> | null = null;
let tokenizationPrerequisiteWarmupCompleted = false;
let tokenizationWarmupCompleted = false;
let tokenizationPlaybackReady = false;
const markTokenizationPrerequisiteWarmupCompleted = (): void => {
tokenizationPrerequisiteWarmupCompleted = true;
};
const markTokenizationPlaybackReady = (): void => {
tokenizationPlaybackReady = true;
};
const markTokenizationWarmupCompleted = (): void => {
tokenizationPrerequisiteWarmupCompleted = true;
tokenizationWarmupCompleted = true;
tokenizationPlaybackReady = true;
};
const backgroundWarmupCoversOnDemandTokenization = (): boolean => {
if (!options.warmups.startBackgroundWarmupsMainDeps.shouldWarmupYomitanExtension()) {
return false;
}
if (
shouldInitializeMecabForAnnotations() &&
!options.warmups.startBackgroundWarmupsMainDeps.shouldWarmupMecab()
) {
return false;
}
if (
shouldWarmupAnnotationDictionaries() &&
!options.warmups.startBackgroundWarmupsMainDeps.shouldWarmupSubtitleDictionaries()
) {
return false;
}
return true;
};
const ensureTokenizationPrerequisites = (): Promise<void> => {
if (tokenizationPrerequisiteWarmupCompleted) {
return Promise.resolve();
@@ -159,7 +190,7 @@ export function composeMpvRuntimeHandlers<
tokenizationPrerequisiteWarmupInFlight = options.warmups.startBackgroundWarmupsMainDeps
.ensureYomitanExtensionLoaded()
.then(() => {
tokenizationPrerequisiteWarmupCompleted = true;
markTokenizationPrerequisiteWarmupCompleted();
})
.finally(() => {
tokenizationPrerequisiteWarmupInFlight = null;
@@ -184,7 +215,7 @@ export function composeMpvRuntimeHandlers<
warmupTasks.push(prewarmSubtitleDictionaries().catch(() => {}));
}
await Promise.all(warmupTasks);
tokenizationWarmupCompleted = true;
markTokenizationWarmupCompleted();
})().finally(() => {
tokenizationWarmupInFlight = null;
});
@@ -198,6 +229,7 @@ export function composeMpvRuntimeHandlers<
if (shouldWarmupAnnotationDictionaries()) {
const onTokenizationReady = tokenizerMainDeps.onTokenizationReady;
tokenizerMainDeps.onTokenizationReady = (tokenizedText: string): void => {
markTokenizationPlaybackReady();
onTokenizationReady?.(tokenizedText);
if (!tokenizationWarmupCompleted) {
void prewarmSubtitleDictionaries({ showLoadingOsd: true }).catch(() => {});
@@ -221,6 +253,36 @@ export function composeMpvRuntimeHandlers<
launchTask: (label, task) => launchBackgroundWarmupTask(label, task),
createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(),
prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(),
onYomitanExtensionWarmupScheduled: (promise) => {
if (tokenizationPrerequisiteWarmupCompleted) {
return;
}
const finalizedPromise = promise
.then(() => {
markTokenizationPrerequisiteWarmupCompleted();
})
.finally(() => {
if (tokenizationPrerequisiteWarmupInFlight === finalizedPromise) {
tokenizationPrerequisiteWarmupInFlight = null;
}
});
tokenizationPrerequisiteWarmupInFlight = finalizedPromise;
},
onTokenizationWarmupScheduled: (promise) => {
if (tokenizationWarmupCompleted || !backgroundWarmupCoversOnDemandTokenization()) {
return;
}
const finalizedPromise = promise
.then(() => {
markTokenizationWarmupCompleted();
})
.finally(() => {
if (tokenizationWarmupInFlight === finalizedPromise) {
tokenizationWarmupInFlight = null;
}
});
tokenizationWarmupInFlight = finalizedPromise;
},
})(),
);
@@ -232,6 +294,7 @@ export function composeMpvRuntimeHandlers<
createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(),
prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(),
startTokenizationWarmups,
isTokenizationWarmupReady: () => tokenizationPlaybackReady,
launchBackgroundWarmupTask: (label, task) => launchBackgroundWarmupTask(label, task),
startBackgroundWarmups: () => startBackgroundWarmups(),
};

View File

@@ -35,6 +35,18 @@ export function createBuildStartBackgroundWarmupsMainDepsHandler(
shouldWarmupJellyfinRemoteSession: () => deps.shouldWarmupJellyfinRemoteSession(),
shouldAutoConnectJellyfinRemote: () => deps.shouldAutoConnectJellyfinRemote(),
startJellyfinRemoteSession: () => deps.startJellyfinRemoteSession(),
...(deps.onYomitanExtensionWarmupScheduled
? {
onYomitanExtensionWarmupScheduled: (promise: Promise<void>) =>
deps.onYomitanExtensionWarmupScheduled!(promise),
}
: {}),
...(deps.onTokenizationWarmupScheduled
? {
onTokenizationWarmupScheduled: (promise: Promise<void>) =>
deps.onTokenizationWarmupScheduled!(promise),
}
: {}),
logDebug: deps.logDebug,
});
}

View File

@@ -30,6 +30,8 @@ export function createStartBackgroundWarmupsHandler(deps: {
shouldWarmupJellyfinRemoteSession: () => boolean;
shouldAutoConnectJellyfinRemote: () => boolean;
startJellyfinRemoteSession: () => Promise<void>;
onYomitanExtensionWarmupScheduled?: (promise: Promise<void>) => void;
onTokenizationWarmupScheduled?: (promise: Promise<void>) => void;
logDebug?: (message: string) => void;
}) {
return (): void => {
@@ -46,9 +48,7 @@ export function createStartBackgroundWarmupsHandler(deps: {
const shouldWarmupTokenization =
warmupMecab || warmupYomitanExtension || warmupSubtitleDictionaries;
if (shouldWarmupTokenization) {
deps.launchTask('subtitle-tokenization', async () => {
await Promise.all([
warmupYomitanExtension
const yomitanWarmupPromise = warmupYomitanExtension
? (async () => {
deps.logDebug?.('[startup-warmup] stage start: yomitan-extension');
await deps.ensureYomitanExtensionLoaded();
@@ -56,7 +56,13 @@ export function createStartBackgroundWarmupsHandler(deps: {
})()
: Promise.resolve().then(() => {
deps.logDebug?.('[startup-warmup] stage skipped: yomitan-extension');
}),
});
if (warmupYomitanExtension) {
deps.onYomitanExtensionWarmupScheduled?.(yomitanWarmupPromise);
}
const tokenizationWarmupPromise = Promise.all([
yomitanWarmupPromise,
warmupMecab
? (async () => {
deps.logDebug?.('[startup-warmup] stage start: mecab');
@@ -75,8 +81,9 @@ export function createStartBackgroundWarmupsHandler(deps: {
: Promise.resolve().then(() => {
deps.logDebug?.('[startup-warmup] stage skipped: subtitle-dictionaries');
}),
]);
});
]).then(() => {});
deps.onTokenizationWarmupScheduled?.(tokenizationWarmupPromise);
deps.launchTask('subtitle-tokenization', () => tokenizationWarmupPromise);
}
if (warmupJellyfinRemoteSession && autoConnectJellyfinRemote) {
deps.launchTask('jellyfin-remote-session', async () => {