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
+2 -2
View File
@@ -38,7 +38,7 @@
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.5",
"fast-uri": "3.1.6",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.1",
@@ -406,7 +406,7 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
"fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+1 -1
View File
@@ -88,7 +88,7 @@
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.5",
"fast-uri": "3.1.6",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.1",
+9 -9
View File
@@ -1,6 +1,6 @@
pkgbase = subminer-bin
pkgdesc = All-in-one sentence mining overlay with AnkiConnect and dictionary integration
pkgver = 0.6.2
pkgver = 0.19.5
pkgrel = 2
url = https://github.com/ksyasuda/SubMiner
arch = x86_64
@@ -22,16 +22,16 @@ pkgbase = subminer-bin
optdepends = alass-git: preferred subtitle synchronization engine
optdepends = python-ffsubsync: fallback subtitle synchronization engine
optdepends = mangatan-extension-server: anime browser bridge shared with Mangatan; skips the bundle download and is updated by pacman
provides = subminer=0.6.2
provides = subminer=0.19.5
conflicts = subminer
noextract = SubMiner-0.6.2.AppImage
noextract = SubMiner-0.19.5.AppImage
options = !strip
options = !debug
source = SubMiner-0.6.2.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/SubMiner-0.6.2.AppImage
source = subminer-0.6.2::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer
source = subminer-assets-0.6.2.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer-assets.tar.gz
sha256sums = c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e
sha256sums = 85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5
sha256sums = 210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1
source = SubMiner-0.19.5.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v0.19.5/SubMiner-0.19.5.AppImage
source = subminer-0.19.5::https://github.com/ksyasuda/SubMiner/releases/download/v0.19.5/subminer
source = subminer-assets-0.19.5.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v0.19.5/subminer-assets.tar.gz
sha256sums = bd683d949956ff847927f23acfede49efad6320cf7ec4cdc3cbd586a5287884e
sha256sums = 2fa292f672df715461765aef8d5c6bf3612004c8b8070b1b1f0afa10d4b74c1e
sha256sums = 591468a1fc68a13de315acaf1154df9da41fe3bcbc0e5522a04ba21e939ed1ae
pkgname = subminer-bin
+4 -4
View File
@@ -1,7 +1,7 @@
# Maintainer: Kyle Yasuda <suda@sudacode.com>
pkgname=subminer-bin
pkgver=0.6.2
pkgver=0.19.5
pkgrel=2
pkgdesc='All-in-one sentence mining overlay with AnkiConnect and dictionary integration'
arch=('x86_64')
@@ -37,9 +37,9 @@ source=(
"subminer-assets-${pkgver}.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer-assets.tar.gz"
)
sha256sums=(
'c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e'
'85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5'
'210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1'
'bd683d949956ff847927f23acfede49efad6320cf7ec4cdc3cbd586a5287884e'
'2fa292f672df715461765aef8d5c6bf3612004c8b8070b1b1f0afa10d4b74c1e'
'591468a1fc68a13de315acaf1154df9da41fe3bcbc0e5522a04ba21e939ed1ae'
)
noextract=("SubMiner-${pkgver}.AppImage")
@@ -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;
+30 -9
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;
}
const stopLexicalBackfill = this.destroyLexicalRollupBackfillRunner();
const pendingLexicalBackfill = this.lexicalRollupBackfillTask;
const finish = (): void => {
this.finalizeActiveSession();
this.isDestroyed = true;
this.deleteMaintenanceScheduler.destroy();
this.destroyDeleteMaintenanceRunner();
this.destroyVocabularySummaryRunner();
this.destroyLexicalRollupBackfillRunner();
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;