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:
2026-09-02 10:51:20 -07:00
parent b01f0bcb4f
commit 0d66747f2e
15 changed files with 213 additions and 50 deletions
@@ -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;
+36 -15
View File
@@ -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',
+4 -4
View File
@@ -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;
}>;
+1
View File
@@ -324,6 +324,7 @@ export interface ResolvedConfig {
extensionsDir: string;
repos: string[];
preferredQuality: string;
bridgeDir: string;
};
jimaku: JimakuConfig & {
apiBaseUrl: string;