mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
fix(immersion): await lexical rollup shutdown during app quit
- Rebuild lifetime completion after stream watch-state changes - Update release packaging metadata and fast-uri
This commit is contained in:
@@ -617,6 +617,72 @@ test('tracker starts the injected lexical rollup backfill when it is pending', a
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy waits for lexical backfill shutdown before finalizing and draining writes', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
let releaseBackfill = (): void => {};
|
||||
let releaseTermination = (): void => {};
|
||||
const heldBackfill = new Promise<void>((resolve) => {
|
||||
releaseBackfill = resolve;
|
||||
});
|
||||
const heldTermination = new Promise<void>((resolve) => {
|
||||
releaseTermination = resolve;
|
||||
});
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
const setupDb = new Database(dbPath);
|
||||
const { ensureSchema } = await import('./immersion-tracker/storage');
|
||||
ensureSchema(setupDb);
|
||||
setupDb
|
||||
.prepare(
|
||||
`UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = 'lexical_daily_rollups_version'`,
|
||||
)
|
||||
.run();
|
||||
setupDb.close();
|
||||
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor(
|
||||
{ dbPath },
|
||||
{
|
||||
runLexicalRollupBackfillTask: async () => heldBackfill,
|
||||
destroyLexicalRollupBackfillRunner: async () => {
|
||||
calls.push('stop');
|
||||
releaseBackfill();
|
||||
await heldTermination;
|
||||
calls.push('stopped');
|
||||
},
|
||||
},
|
||||
);
|
||||
tracker.handleMediaChange('/tmp/destroy-backfill.mkv', 'Destroy Backfill');
|
||||
tracker.recordCardsMined(1);
|
||||
|
||||
const privateApi = tracker as unknown as {
|
||||
queue: unknown[];
|
||||
finalizeActiveSession: () => void;
|
||||
};
|
||||
const finalizeActiveSession = privateApi.finalizeActiveSession.bind(tracker);
|
||||
privateApi.finalizeActiveSession = () => {
|
||||
finalizeActiveSession();
|
||||
calls.push(`finalized:${privateApi.queue.length}`);
|
||||
};
|
||||
|
||||
const destroyTask = tracker.destroy();
|
||||
assert.ok(destroyTask instanceof Promise);
|
||||
assert.deepEqual(calls, ['stop']);
|
||||
assert.ok(privateApi.queue.length > 0);
|
||||
|
||||
releaseTermination();
|
||||
await destroyTask;
|
||||
assert.deepEqual(calls, ['stop', 'stopped', 'finalized:0']);
|
||||
} finally {
|
||||
releaseBackfill();
|
||||
releaseTermination();
|
||||
await tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('tracker runs startup session-rollup maintenance before lexical backfill locks writes', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -463,7 +463,7 @@ export class ImmersionTrackerService {
|
||||
private readonly vocabularySummariesInFlight = new Map<string, Promise<VocabularyStatsSummary>>();
|
||||
private readonly destroyVocabularySummaryRunner: () => void;
|
||||
private readonly runLexicalRollupBackfillTask: () => Promise<void>;
|
||||
private readonly destroyLexicalRollupBackfillRunner: () => void;
|
||||
private readonly destroyLexicalRollupBackfillRunner: () => void | Promise<void>;
|
||||
private readonly deleteMaintenanceScheduler: DeleteMaintenanceScheduler;
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -472,6 +472,9 @@ export class ImmersionTrackerService {
|
||||
private preserveWriteQueueUntilDrained = false;
|
||||
private lastVacuumMs = 0;
|
||||
private isDestroyed = false;
|
||||
private isDestroying = false;
|
||||
private lexicalRollupBackfillTask: Promise<void> | null = null;
|
||||
private destroyTask: Promise<void> | null = null;
|
||||
private sessionState: SessionState | null = null;
|
||||
private currentVideoKey = '';
|
||||
private currentMediaPathOrUrl = '';
|
||||
@@ -495,7 +498,7 @@ export class ImmersionTrackerService {
|
||||
runVocabularySummaryTask?: RunVocabularySummaryTask;
|
||||
destroyVocabularySummaryRunner?: () => void;
|
||||
runLexicalRollupBackfillTask?: (dbPath: string) => Promise<void>;
|
||||
destroyLexicalRollupBackfillRunner?: () => void;
|
||||
destroyLexicalRollupBackfillRunner?: () => void | Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
this.dbPath = options.dbPath;
|
||||
@@ -635,8 +638,10 @@ export class ImmersionTrackerService {
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
destroy(): void | Promise<void> {
|
||||
if (this.destroyTask) return this.destroyTask;
|
||||
if (this.isDestroyed) return;
|
||||
this.isDestroying = true;
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
@@ -645,13 +650,28 @@ export class ImmersionTrackerService {
|
||||
clearInterval(this.maintenanceTimer);
|
||||
this.maintenanceTimer = null;
|
||||
}
|
||||
this.finalizeActiveSession();
|
||||
this.isDestroyed = true;
|
||||
this.deleteMaintenanceScheduler.destroy();
|
||||
this.destroyDeleteMaintenanceRunner();
|
||||
this.destroyVocabularySummaryRunner();
|
||||
this.destroyLexicalRollupBackfillRunner();
|
||||
this.db.close();
|
||||
const stopLexicalBackfill = this.destroyLexicalRollupBackfillRunner();
|
||||
const pendingLexicalBackfill = this.lexicalRollupBackfillTask;
|
||||
const finish = (): void => {
|
||||
this.finalizeActiveSession();
|
||||
this.isDestroyed = true;
|
||||
this.deleteMaintenanceScheduler.destroy();
|
||||
this.destroyDeleteMaintenanceRunner();
|
||||
this.destroyVocabularySummaryRunner();
|
||||
this.db.close();
|
||||
};
|
||||
|
||||
if (!stopLexicalBackfill && !pendingLexicalBackfill) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
this.destroyTask = (async () => {
|
||||
await stopLexicalBackfill;
|
||||
await pendingLexicalBackfill;
|
||||
finish();
|
||||
})();
|
||||
return this.destroyTask;
|
||||
}
|
||||
|
||||
async getSessionSummaries(limit = 50): Promise<SessionSummaryQueryRow[]> {
|
||||
@@ -914,14 +934,13 @@ export class ImmersionTrackerService {
|
||||
const statsPath = normalizeMediaPath(episode.statsPath);
|
||||
if (!statsPath) continue;
|
||||
if (watched) {
|
||||
needsLifetimeRebuild =
|
||||
this.recordStreamPlaybackMetadata(episode, { deferLifetimeRebuild: true }) ||
|
||||
needsLifetimeRebuild;
|
||||
this.recordStreamPlaybackMetadata(episode, { deferLifetimeRebuild: true });
|
||||
}
|
||||
|
||||
const videoId = getVideoIdByVideoKey(this.db, buildVideoKey(statsPath, SOURCE_TYPE_REMOTE));
|
||||
if (videoId === null) continue;
|
||||
markVideoWatched(this.db, videoId, watched);
|
||||
needsLifetimeRebuild = true;
|
||||
changed += 1;
|
||||
|
||||
if (!watched && this.sessionState?.videoId === videoId) clearedActiveVideo = true;
|
||||
@@ -1111,7 +1130,7 @@ export class ImmersionTrackerService {
|
||||
this.requireWriteQueueDrained('lexical rollup backfill');
|
||||
this.preserveWriteQueueUntilDrained = true;
|
||||
this.setWriteLock('lexical-rollup-backfill', true);
|
||||
void this.runLexicalRollupBackfillTask()
|
||||
const task = this.runLexicalRollupBackfillTask()
|
||||
.catch((error: unknown) => {
|
||||
this.logger.warn(
|
||||
'Lexical daily rollup backfill failed; it will retry on next startup',
|
||||
@@ -1121,8 +1140,10 @@ export class ImmersionTrackerService {
|
||||
.finally(() => {
|
||||
this.setWriteLock('lexical-rollup-backfill', false);
|
||||
if (this.queue.length === 0) this.preserveWriteQueueUntilDrained = false;
|
||||
else if (!this.isDestroyed) this.scheduleFlush(0);
|
||||
else if (!this.isDestroyed && !this.isDestroying) this.scheduleFlush(0);
|
||||
if (this.lexicalRollupBackfillTask === task) this.lexicalRollupBackfillTask = null;
|
||||
});
|
||||
this.lexicalRollupBackfillTask = task;
|
||||
}
|
||||
|
||||
async reassignAnimeAnilist(
|
||||
|
||||
@@ -116,6 +116,35 @@ test('a batch rebuilds the lifetime summaries once, not once per episode', async
|
||||
}
|
||||
});
|
||||
|
||||
test('watch-state changes rebuild lifetime completion for an already-recorded episode', async () => {
|
||||
const { tracker, dir } = await createTracker();
|
||||
try {
|
||||
const recordedEpisode = episode(EP1, 1);
|
||||
tracker.recordStreamPlaybackMetadata(recordedEpisode);
|
||||
tracker.handleMediaChange(EP1, recordedEpisode.displayTitle);
|
||||
tracker.handleMediaChange(EP2, 'Next Episode');
|
||||
|
||||
const privateApi = tracker as unknown as {
|
||||
db: import('./immersion-tracker/sqlite').DatabaseSync;
|
||||
};
|
||||
const getCompleted = (): number | null =>
|
||||
(
|
||||
privateApi.db
|
||||
.prepare('SELECT completed FROM imm_lifetime_media ORDER BY video_id LIMIT 1')
|
||||
.get() as { completed: number } | null
|
||||
)?.completed ?? null;
|
||||
|
||||
assert.equal(getCompleted(), 0);
|
||||
await tracker.setStreamWatchState([recordedEpisode], true);
|
||||
assert.equal(getCompleted(), 1);
|
||||
await tracker.setStreamWatchState([recordedEpisode], false);
|
||||
assert.equal(getCompleted(), 0);
|
||||
} finally {
|
||||
await tracker.destroy();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an episode with no stats path is skipped rather than recorded as unknown', async () => {
|
||||
const { tracker, dir } = await createTracker();
|
||||
try {
|
||||
|
||||
@@ -134,3 +134,43 @@ test('lexical rollup worker times out when it never responds', async () => {
|
||||
runtime.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy waits for active worker termination', async () => {
|
||||
let releaseTermination = (): void => {};
|
||||
let emitExit: ((code: number) => void) | null = null;
|
||||
const terminationGate = new Promise<void>((resolve) => {
|
||||
releaseTermination = resolve;
|
||||
});
|
||||
const runtime = new LexicalRollupWorkerRuntime({
|
||||
resolveWorkerPath: () => '/tmp/fake-worker.js',
|
||||
createWorker: async () => ({
|
||||
once(event: string, listener: (value: never) => void) {
|
||||
if (event === 'exit') emitExit = listener as (code: number) => void;
|
||||
return this;
|
||||
},
|
||||
terminate: async () => {
|
||||
await terminationGate;
|
||||
emitExit?.(1);
|
||||
return 1;
|
||||
},
|
||||
}),
|
||||
warn: () => {},
|
||||
} as never);
|
||||
|
||||
const runTask = runtime.run('/tmp/not-used.sqlite');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const destroyTask = runtime.destroy();
|
||||
assert.ok(destroyTask instanceof Promise);
|
||||
|
||||
let destroyed = false;
|
||||
void destroyTask.then(() => {
|
||||
destroyed = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(destroyed, false);
|
||||
|
||||
releaseTermination();
|
||||
await destroyTask;
|
||||
await assert.rejects(runTask, /exited with code 1/);
|
||||
assert.equal(destroyed, true);
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ export function resolveLexicalRollupWorkerPath(): string | null {
|
||||
export class LexicalRollupWorkerRuntime {
|
||||
private readonly activeWorkers = new Set<WorkerHandle>();
|
||||
private destroyed = false;
|
||||
private destroyTask: Promise<void> | null = null;
|
||||
|
||||
constructor(private readonly options: LexicalRollupWorkerRuntimeOptions = {}) {}
|
||||
|
||||
@@ -106,12 +107,15 @@ export class LexicalRollupWorkerRuntime {
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
destroy(): void | Promise<void> {
|
||||
if (this.destroyed) return this.destroyTask ?? undefined;
|
||||
this.destroyed = true;
|
||||
for (const worker of this.activeWorkers) {
|
||||
void worker.terminate().catch(() => undefined);
|
||||
}
|
||||
const activeWorkers = [...this.activeWorkers];
|
||||
this.activeWorkers.clear();
|
||||
if (activeWorkers.length === 0) return;
|
||||
this.destroyTask = Promise.all(
|
||||
activeWorkers.map((worker) => worker.terminate().catch(() => undefined)),
|
||||
).then(() => undefined);
|
||||
return this.destroyTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
createShouldRestoreWindowsOnActivateHandler,
|
||||
} from './app-lifecycle-actions';
|
||||
|
||||
test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
test('on will quit cleanup handler runs all cleanup steps', async () => {
|
||||
const calls: string[] = [];
|
||||
const cleanup = createOnWillQuitCleanupHandler({
|
||||
destroyTray: () => calls.push('destroy-tray'),
|
||||
@@ -32,7 +32,9 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
destroyMpvSocket: () => calls.push('destroy-socket'),
|
||||
clearReconnectTimer: () => calls.push('clear-reconnect'),
|
||||
destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'),
|
||||
destroyImmersionTracker: () => calls.push('destroy-immersion'),
|
||||
destroyImmersionTracker: () => {
|
||||
calls.push('destroy-immersion');
|
||||
},
|
||||
destroyAnkiIntegration: () => calls.push('destroy-anki'),
|
||||
destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'),
|
||||
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
||||
@@ -50,7 +52,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
await cleanup();
|
||||
assert.equal(calls.length, 35);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
@@ -63,7 +65,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => {
|
||||
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', async () => {
|
||||
const calls: string[] = [];
|
||||
const cleanup = createOnWillQuitCleanupHandler({
|
||||
destroyTray: () => {},
|
||||
@@ -106,7 +108,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
assert.throws(() => cleanup(), /stop failed/);
|
||||
await assert.rejects(cleanup(), /stop failed/);
|
||||
assert.deepEqual(calls, [
|
||||
'stop-jellyfin-remote',
|
||||
'cleanup-jellyfin-subtitles',
|
||||
|
||||
@@ -18,7 +18,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyMpvSocket: () => void;
|
||||
clearReconnectTimer: () => void;
|
||||
destroySubtitleTimingTracker: () => void;
|
||||
destroyImmersionTracker: () => void;
|
||||
destroyImmersionTracker: () => void | Promise<void>;
|
||||
destroyAnkiIntegration: () => void;
|
||||
destroyAnilistSetupWindow: () => void;
|
||||
clearAnilistSetupWindow: () => void;
|
||||
@@ -35,7 +35,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return (): Promise<void> => {
|
||||
return async (): Promise<void> => {
|
||||
deps.destroyTray();
|
||||
deps.stopConfigHotReload();
|
||||
deps.restorePreviousSecondarySubVisibility();
|
||||
@@ -55,7 +55,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.destroyMpvSocket();
|
||||
deps.clearReconnectTimer();
|
||||
deps.destroySubtitleTimingTracker();
|
||||
deps.destroyImmersionTracker();
|
||||
await deps.destroyImmersionTracker();
|
||||
deps.destroyAnkiIntegration();
|
||||
deps.destroyAnilistSetupWindow();
|
||||
deps.clearAnilistSetupWindow();
|
||||
@@ -77,7 +77,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.cleanupYoutubeSubtitleTempDirs();
|
||||
deps.cleanupYoutubeMediaCache();
|
||||
deps.stopDiscordPresenceService();
|
||||
return Promise.resolve(stopSyncAutoScheduler);
|
||||
await stopSyncAutoScheduler;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
||||
import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup';
|
||||
import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions';
|
||||
|
||||
test('cleanup deps builder returns handlers that guard optional runtime objects', () => {
|
||||
test('cleanup deps builder returns handlers that guard optional runtime objects', async () => {
|
||||
const calls: string[] = [];
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 60_000);
|
||||
let immersionTracker: { destroy: () => void } | null = {
|
||||
@@ -80,7 +80,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
});
|
||||
|
||||
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
|
||||
cleanup();
|
||||
await cleanup();
|
||||
|
||||
assert.ok(calls.includes('destroy-tray'));
|
||||
assert.ok(calls.includes('destroy-main-overlay-window'));
|
||||
|
||||
@@ -119,10 +119,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
destroySubtitleTimingTracker: () => {
|
||||
deps.getSubtitleTimingTracker()?.destroy();
|
||||
},
|
||||
destroyImmersionTracker: () => {
|
||||
destroyImmersionTracker: async () => {
|
||||
const tracker = deps.getImmersionTracker();
|
||||
if (!tracker) return;
|
||||
tracker.destroy();
|
||||
await tracker.destroy();
|
||||
deps.clearImmersionTracker();
|
||||
},
|
||||
destroyAnkiIntegration: () => {
|
||||
|
||||
@@ -32,7 +32,7 @@ export type StartupLifecycleComposerOptions = ComposerInputs<{
|
||||
|
||||
export type StartupLifecycleComposerResult = ComposerOutputs<{
|
||||
registerProtocolUrlHandlers: () => void;
|
||||
onWillQuitCleanup: () => void;
|
||||
onWillQuitCleanup: () => Promise<void>;
|
||||
shouldRestoreWindowsOnActivate: () => boolean;
|
||||
restoreWindowsOnActivate: () => void;
|
||||
}>;
|
||||
|
||||
@@ -324,6 +324,7 @@ export interface ResolvedConfig {
|
||||
extensionsDir: string;
|
||||
repos: string[];
|
||||
preferredQuality: string;
|
||||
bridgeDir: string;
|
||||
};
|
||||
jimaku: JimakuConfig & {
|
||||
apiBaseUrl: string;
|
||||
|
||||
Reference in New Issue
Block a user