fix(stats): harden server lifecycle and verify compiled runtime (#261)

This commit is contained in:
2026-09-20 23:19:03 -07:00
committed by GitHub
parent f9c4e892dc
commit 383aed8bad
37 changed files with 1381 additions and 323 deletions
+63 -6
View File
@@ -1,12 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createForceQuitHandler,
createOnWillQuitCleanupHandler,
createRestoreWindowsOnActivateHandler,
createShouldRestoreWindowsOnActivateHandler,
} from './app-lifecycle-actions';
test('on will quit cleanup handler runs all cleanup steps', () => {
test('forced quit finalizes stats before exiting, even when finalization throws', async () => {
for (const fails of [false, true]) {
const calls: string[] = [];
await createForceQuitHandler({
destroyImmersionTracker: () => {
calls.push('finalize');
if (fails) throw new Error('flush failed');
},
logError: () => {
calls.push('error');
},
exit: () => {
calls.push('exit');
},
})();
assert.deepEqual(calls, fails ? ['finalize', 'error', 'exit'] : ['finalize', 'exit']);
}
});
test('on will quit cleanup handler runs all cleanup steps', async () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => calls.push('destroy-tray'),
@@ -32,7 +52,15 @@ 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'),
stopStatsServer: async () => {
calls.push('stop-stats-server-start');
await Promise.resolve();
calls.push('stop-stats-server-complete');
},
destroyImmersionTracker: async () => {
await Promise.resolve();
calls.push('destroy-immersion');
},
destroyAnkiIntegration: () => calls.push('destroy-anki'),
destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'),
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
@@ -51,8 +79,8 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
cleanup();
assert.equal(calls.length, 36);
await cleanup();
assert.equal(calls.length, 38);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
@@ -63,9 +91,37 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.includes('cleanup-remote-media-windows'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
assert.ok(calls.indexOf('stop-stats-server-complete') < calls.indexOf('destroy-immersion'));
assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('destroy-anki'));
});
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => {
test('forced quit waits for asynchronous stats finalization', async () => {
const calls: string[] = [];
await createForceQuitHandler({
destroyImmersionTracker: async () => {
await Promise.resolve();
calls.push('finalized');
},
logError: () => calls.push('error'),
exit: () => calls.push('exit'),
})();
assert.deepEqual(calls, ['finalized', 'exit']);
});
test('forced quit exits when asynchronous stats finalization never settles', async () => {
const calls: string[] = [];
await createForceQuitHandler({
destroyImmersionTracker: () => new Promise<void>(() => {}),
logError: (error) => {
assert.match(String(error), /Stats finalization timed out/);
calls.push('timeout');
},
exit: () => calls.push('exit'),
})();
assert.deepEqual(calls, ['timeout', 'exit']);
});
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', async () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => {},
@@ -87,6 +143,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
destroyMpvSocket: () => {},
clearReconnectTimer: () => {},
destroySubtitleTimingTracker: () => {},
stopStatsServer: () => {},
destroyImmersionTracker: () => {},
destroyAnkiIntegration: () => {},
destroyAnilistSetupWindow: () => {},
@@ -109,7 +166,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',
+46 -5
View File
@@ -1,3 +1,26 @@
export function createForceQuitHandler(deps: {
destroyImmersionTracker: () => void | Promise<void>;
logError: (error: unknown) => void;
exit: () => void;
}) {
return async () => {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
Promise.resolve().then(() => deps.destroyImmersionTracker()),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error('Stats finalization timed out.')), 1_000);
}),
]);
} catch (error) {
deps.logError(error);
} finally {
clearTimeout(timeout);
deps.exit();
}
};
}
export function createOnWillQuitCleanupHandler(deps: {
destroyTray: () => void;
stopConfigHotReload: () => void;
@@ -18,7 +41,8 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyMpvSocket: () => void;
clearReconnectTimer: () => void;
destroySubtitleTimingTracker: () => void;
destroyImmersionTracker: () => void;
stopStatsServer: () => Promise<void> | void;
destroyImmersionTracker: () => void | Promise<void>;
destroyAnkiIntegration: () => void;
destroyAnilistSetupWindow: () => void;
clearAnilistSetupWindow: () => void;
@@ -36,7 +60,7 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
return (): Promise<void> => {
return async (): Promise<void> => {
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
@@ -44,7 +68,12 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
const stopSyncAutoScheduler = deps.stopSyncAutoScheduler();
const cleanupErrors: unknown[] = [];
const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch(
(error: unknown) => {
cleanupErrors.push(error);
},
);
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow();
@@ -56,7 +85,16 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.destroyMpvSocket();
deps.clearReconnectTimer();
deps.destroySubtitleTimingTracker();
deps.destroyImmersionTracker();
try {
await deps.stopStatsServer();
} catch (error) {
cleanupErrors.push(error);
}
try {
await deps.destroyImmersionTracker();
} catch (error) {
cleanupErrors.push(error);
}
deps.destroyAnkiIntegration();
deps.destroyAnilistSetupWindow();
deps.clearAnilistSetupWindow();
@@ -79,7 +117,10 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.cleanupYoutubeMediaCache();
deps.cleanupRemoteMediaWindows();
deps.stopDiscordPresenceService();
return Promise.resolve(stopSyncAutoScheduler);
await stopSyncAutoScheduler;
if (cleanupErrors.length > 0) {
throw cleanupErrors[0];
}
};
}
@@ -3,11 +3,14 @@ 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 = {
destroy: () => calls.push('destroy-immersion'),
let immersionTracker: { destroy: () => Promise<void> } | null = {
destroy: async () => {
await Promise.resolve();
calls.push('destroy-immersion');
},
};
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
@@ -54,6 +57,9 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }),
getImmersionTracker: () => immersionTracker,
stopStatsServer: () => {
calls.push('stop-stats-server');
},
clearImmersionTracker: () => {
immersionTracker = null;
calls.push('clear-immersion-ref');
@@ -81,7 +87,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'));
@@ -94,6 +100,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('clear-reconnect-ref'));
assert.ok(calls.includes('destroy-immersion'));
assert.ok(calls.includes('clear-immersion-ref'));
assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('clear-immersion-ref'));
assert.ok(calls.includes('destroy-first-run-window'));
assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote'));
@@ -144,6 +151,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
stopStatsServer: () => {},
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
@@ -198,6 +206,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
stopStatsServer: () => {},
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
@@ -44,7 +44,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearReconnectTimerRef: () => void;
getSubtitleTimingTracker: () => Destroyable | null;
getImmersionTracker: () => Destroyable | null;
getImmersionTracker: () => { destroy: () => void | Promise<void> } | null;
stopStatsServer: () => Promise<void> | void;
clearImmersionTracker: () => void;
getAnkiIntegration: () => Destroyable | null;
@@ -120,10 +121,11 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
destroySubtitleTimingTracker: () => {
deps.getSubtitleTimingTracker()?.destroy();
},
destroyImmersionTracker: () => {
stopStatsServer: () => deps.stopStatsServer(),
destroyImmersionTracker: async () => {
const tracker = deps.getImmersionTracker();
if (!tracker) return;
tracker.destroy();
await tracker.destroy();
deps.clearImmersionTracker();
},
destroyAnkiIntegration: () => {
@@ -24,10 +24,10 @@ function createDeps(
return { deps, calls };
}
test('ensures background stats server and logs local startup', () => {
test('ensures background stats server and logs local startup', async () => {
const { deps, calls } = createDeps();
createEnsureBackgroundStatsServerHandler(deps)();
await createEnsureBackgroundStatsServerHandler(deps)();
assert.ok(calls.includes('ensureBackgroundStatsServerStarted'));
assert.ok(
@@ -35,7 +35,7 @@ test('ensures background stats server and logs local startup', () => {
);
});
test('logs reuse when a background stats server is already running', () => {
test('logs reuse when a background stats server is already running', async () => {
const { deps, calls } = createDeps({
ensureBackgroundStatsServerStarted: () => ({
url: 'http://127.0.0.1:3888',
@@ -43,36 +43,53 @@ test('logs reuse when a background stats server is already running', () => {
}),
});
createEnsureBackgroundStatsServerHandler(deps)();
await createEnsureBackgroundStatsServerHandler(deps)();
assert.ok(
calls.some((value) => value.startsWith('info:') && /already running|reusing/i.test(value)),
);
});
test('skips when stats.autoStartServer is disabled', () => {
test('skips when stats.autoStartServer is disabled', async () => {
const { deps, calls } = createDeps({ isStatsAutoStartEnabled: () => false });
createEnsureBackgroundStatsServerHandler(deps)();
await createEnsureBackgroundStatsServerHandler(deps)();
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
});
test('skips when immersion tracking is disabled', () => {
test('skips when immersion tracking is disabled', async () => {
const { deps, calls } = createDeps({ isImmersionTrackingEnabled: () => false });
createEnsureBackgroundStatsServerHandler(deps)();
await createEnsureBackgroundStatsServerHandler(deps)();
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
});
test('logs a warning instead of throwing when startup fails', () => {
test('logs a warning instead of throwing when startup fails', async () => {
const { deps, calls } = createDeps({
ensureBackgroundStatsServerStarted: () => {
throw new Error('port in use');
},
});
assert.doesNotThrow(() => createEnsureBackgroundStatsServerHandler(deps)());
await assert.doesNotReject(createEnsureBackgroundStatsServerHandler(deps)());
assert.ok(calls.some((value) => value.startsWith('warn:')));
});
test('logs an asynchronously reported startup failure', async () => {
const { deps, calls } = createDeps({
ensureBackgroundStatsServerStarted: async () => {
await Promise.resolve();
throw new Error('address in use');
},
});
await createEnsureBackgroundStatsServerHandler(deps)();
assert.ok(calls.some((value) => value.startsWith('warn:')));
assert.equal(
calls.some((value) => value.startsWith('info:')),
false,
);
});
+12 -7
View File
@@ -1,18 +1,23 @@
export interface EnsureBackgroundStatsServerDeps {
isStatsAutoStartEnabled: () => boolean;
isImmersionTrackingEnabled: () => boolean;
ensureBackgroundStatsServerStarted: () => {
url: string;
runningInCurrentProcess: boolean;
};
ensureBackgroundStatsServerStarted: () =>
| Promise<{
url: string;
runningInCurrentProcess: boolean;
}>
| {
url: string;
runningInCurrentProcess: boolean;
};
logInfo: (message: string) => void;
logWarn: (message: string, error?: unknown) => void;
}
export function createEnsureBackgroundStatsServerHandler(
deps: EnsureBackgroundStatsServerDeps,
): () => void {
return () => {
): () => Promise<void> {
return async () => {
if (!deps.isStatsAutoStartEnabled()) {
deps.logInfo('Background start: stats.autoStartServer is disabled; skipping stats server.');
return;
@@ -22,7 +27,7 @@ export function createEnsureBackgroundStatsServerHandler(
return;
}
try {
const result = deps.ensureBackgroundStatsServerStarted();
const result = await deps.ensureBackgroundStatsServerStarted();
deps.logInfo(
result.runningInCurrentProcess
? `Background start: stats server started at ${result.url}.`
@@ -38,6 +38,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
stopStatsServer: () => {},
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
@@ -32,7 +32,7 @@ export type StartupLifecycleComposerOptions = ComposerInputs<{
export type StartupLifecycleComposerResult = ComposerOutputs<{
registerProtocolUrlHandlers: () => void;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
}>;
+6 -4
View File
@@ -57,8 +57,10 @@ export function createRunStatsCliCommandHandler(deps: {
}) => Promise<DuplicateSubtitleLineCleanupSummary>;
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
} | null;
ensureStatsServerStarted: () => string;
ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult;
ensureStatsServerStarted: () => Promise<string> | string;
ensureBackgroundStatsServerStarted: () =>
| Promise<BackgroundStatsStartResult>
| BackgroundStatsStartResult;
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
openExternal: (url: string) => Promise<unknown>;
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
@@ -115,7 +117,7 @@ export function createRunStatsCliCommandHandler(deps: {
}
if (args.statsBackground) {
const result = deps.ensureBackgroundStatsServerStarted();
const result = await deps.ensureBackgroundStatsServerStarted();
deps.logInfo(`Stats dashboard available at ${result.url}`);
writeResponseSafe(args.statsResponsePath, { ok: true, url: result.url });
if (!result.runningInCurrentProcess && source === 'initial') {
@@ -183,7 +185,7 @@ export function createRunStatsCliCommandHandler(deps: {
return;
}
const url = deps.ensureStatsServerStarted();
const url = await deps.ensureStatsServerStarted();
if (config.stats.autoOpenBrowser !== false) {
await deps.openExternal(url);
}
@@ -23,7 +23,7 @@ function createHarness(options?: {
return options?.processAlive ?? true;
},
hasLocalStatsServer: () => localServerStarted,
startLocalStatsServer: () => {
startLocalStatsServer: async () => {
calls.push('startLocalStatsServer');
localServerStarted = true;
},
@@ -36,23 +36,23 @@ function createHarness(options?: {
};
}
test('stats server routing defers to a live background daemon from another process', () => {
test('stats server routing defers to a live background daemon from another process', async () => {
const { calls, handler } = createHarness({
state: { pid: 200, port: 7979, startedAtMs: 1 },
processAlive: true,
});
assert.deepEqual(handler(), { url: 'http://127.0.0.1:7979', source: 'background' });
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:7979', source: 'background' });
assert.deepEqual(calls, ['readBackgroundState', 'isProcessAlive']);
});
test('stats server routing clears dead daemon state and starts local server', () => {
test('stats server routing clears dead daemon state and starts local server', async () => {
const { calls, handler } = createHarness({
state: { pid: 200, port: 7979, startedAtMs: 1 },
processAlive: false,
});
assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
assert.deepEqual(calls, [
'readBackgroundState',
'isProcessAlive',
@@ -61,13 +61,13 @@ test('stats server routing clears dead daemon state and starts local server', ()
]);
});
test('stats server routing clears self-owned stale state and starts local server', () => {
test('stats server routing clears self-owned stale state and starts local server', async () => {
const { calls, handler } = createHarness({
state: { pid: 100, port: 7979, startedAtMs: 1 },
processAlive: true,
});
assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
assert.deepEqual(calls, [
'readBackgroundState',
'removeBackgroundState',
@@ -75,12 +75,12 @@ test('stats server routing clears self-owned stale state and starts local server
]);
});
test('stats server routing reuses a started local stats server', () => {
test('stats server routing reuses a started local stats server', async () => {
const { calls, handler } = createHarness({
state: null,
localServerStarted: true,
});
assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
assert.deepEqual(calls, ['readBackgroundState', 'removeBackgroundState']);
});
+4 -4
View File
@@ -6,7 +6,7 @@ type EnsureStatsServerUrlDeps = {
removeBackgroundState: () => void;
isProcessAlive: (pid: number) => boolean;
hasLocalStatsServer: () => boolean;
startLocalStatsServer: () => void;
startLocalStatsServer: () => Promise<void>;
getConfiguredPort: () => number;
};
@@ -18,8 +18,8 @@ export type EnsureStatsServerUrlResult = { url: string; source: 'background' | '
export function createEnsureStatsServerUrlHandler(
deps: EnsureStatsServerUrlDeps,
): () => EnsureStatsServerUrlResult {
return () => {
): () => Promise<EnsureStatsServerUrlResult> {
return async () => {
const state = deps.readBackgroundState();
if (!state) {
deps.removeBackgroundState();
@@ -32,7 +32,7 @@ export function createEnsureStatsServerUrlHandler(
}
if (!deps.hasLocalStatsServer()) {
deps.startLocalStatsServer();
await deps.startLocalStatsServer();
}
return { url: formatStatsServerUrl(deps.getConfiguredPort()), source: 'local' };
};
+223 -6
View File
@@ -1,10 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import test, { after } from 'node:test';
import { DEFAULT_CONFIG } from '../../config';
import { ImmersionTrackerService } from '../../core/services/immersion-tracker-service';
import { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
import {
createStatsServerRuntime,
isSelfOwnedBackgroundStatsDaemonState,
shouldClearAppStateStatsServerOnStop,
type StatsServerRuntimeDeps,
} from './stats-server-runtime';
import type { StatsServer } from '../../core/services/stats-server';
import type { BackgroundStatsServerState } from './stats-daemon';
function createDeferred<T>() {
let settle: ((value: T) => void) | null = null;
let fail: ((error: unknown) => void) | null = null;
const promise = new Promise<T>((resolve, reject) => {
settle = resolve;
fail = reject;
});
return {
promise,
resolve(value: T): void {
if (!settle) throw new Error('deferred promise is unavailable');
settle(value);
},
reject(error: unknown): void {
if (!fail) throw new Error('deferred promise is unavailable');
fail(error);
},
};
}
function createRuntimeHarness(
startServer: NonNullable<StatsServerRuntimeDeps['startServer']>,
backgroundState: BackgroundStatsServerState | null = null,
) {
const appStateValues: Array<StatsServer | null> = [];
const tracker = new ImmersionTrackerService({ dbPath: ':memory:' });
after(() => tracker.destroy());
const runtime = createStatsServerRuntime({
userDataPath: '/tmp/subminer-stats-runtime-test',
statsDistPath: '/tmp/stats-dist',
getResolvedConfig: () => ({
...DEFAULT_CONFIG,
stats: { ...DEFAULT_CONFIG.stats, serverPort: 5175 },
}),
getImmersionTracker: () => tracker,
setAppStateStatsServer: (server) => {
appStateValues.push(server);
},
getMpvSocketPath: () => '/tmp/mpv.sock',
getYomitanExt: () => null,
getYomitanSession: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
getYomitanAnkiDeckName: async () => 'Mining',
getAnilistRateLimiter: () => createAnilistRateLimiter(),
resolveAnkiNoteId: (noteId) => noteId,
trackDuplicateNoteIdsForNote: () => {},
resolveSentenceSearchHeadwords: async () => [],
ensureImmersionTrackerStarted: () => {},
setStatsStartupInProgress: () => {},
readBackgroundStatsServerState: () => backgroundState,
removeBackgroundStatsServerState: () => {},
isBackgroundStatsServerProcessAlive: () => false,
startServer,
});
return { runtime, appStateValues };
}
test('detects self-owned background stats daemon state', () => {
assert.equal(
@@ -13,10 +80,6 @@ test('detects self-owned background stats daemon state', () => {
);
});
test('stats server app-state reference should be cleared after private server stop', () => {
assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true);
});
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
const calls: string[] = [];
const runtime = createStatsServerRuntime({
@@ -57,3 +120,157 @@ test('stopBackgroundStatsServer clears stale state when daemon identity mismatch
assert.deepEqual(result, { ok: true, stale: true });
assert.deepEqual(calls, ['removeBackgroundStatsServerState']);
});
test('concurrent stats startup requests share one pending server', async () => {
const deferred = createDeferred<StatsServer>();
let startCalls = 0;
const server: StatsServer = { close: async () => {} };
const { runtime, appStateValues } = createRuntimeHarness(() => {
startCalls += 1;
return deferred.promise;
});
const first = runtime.ensureStatsServerStarted();
const second = runtime.ensureStatsServerStarted();
assert.equal(startCalls, 1);
assert.deepEqual(appStateValues, []);
deferred.resolve(server);
assert.deepEqual(await Promise.all([first, second]), [
{ url: 'http://127.0.0.1:5175', source: 'local' },
{ url: 'http://127.0.0.1:5175', source: 'local' },
]);
assert.deepEqual(appStateValues, [server]);
});
test('failed stats startup remains recoverable on the next request', async () => {
const first = createDeferred<StatsServer>();
const second = createDeferred<StatsServer>();
const attempts = [first, second];
let startCalls = 0;
const server: StatsServer = { close: async () => {} };
const { runtime, appStateValues } = createRuntimeHarness(() => {
const attempt = attempts[startCalls];
startCalls += 1;
if (!attempt) throw new Error('unexpected startup attempt');
return attempt.promise;
});
const failedStartup = runtime.ensureStatsServerStarted();
first.reject(Object.assign(new Error('address in use'), { code: 'EADDRINUSE' }));
await assert.rejects(failedStartup, /address in use/);
const retry = runtime.ensureStatsServerStarted();
second.resolve(server);
assert.deepEqual(await retry, { url: 'http://127.0.0.1:5175', source: 'local' });
assert.equal(startCalls, 2);
assert.deepEqual(appStateValues, [null, server]);
});
test('shutdown cancels pending startup and closes the late server', async () => {
const deferred = createDeferred<StatsServer>();
let closeCalls = 0;
const server: StatsServer = {
close: async () => {
closeCalls += 1;
},
};
const { runtime, appStateValues } = createRuntimeHarness(() => deferred.promise);
const startup = runtime.ensureStatsServerStarted();
const shutdown = runtime.stopStatsServer();
deferred.resolve(server);
await assert.rejects(startup, /startup was cancelled/);
await shutdown;
assert.equal(closeCalls, 1);
assert.deepEqual(appStateValues, [null, null]);
});
test('stopping a self-owned background server closes its local handle', async () => {
let closeCalls = 0;
const server: StatsServer = {
close: async () => {
closeCalls += 1;
},
};
const { runtime } = createRuntimeHarness(async () => server, {
pid: process.pid,
port: 5175,
startedAtMs: 1,
});
await runtime.ensureStatsServerStarted();
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: false });
assert.equal(closeCalls, 1);
});
test('background stop leaves a foreground-only server available', async () => {
let closeCalls = 0;
let startCalls = 0;
const { runtime } = createRuntimeHarness(async () => {
startCalls += 1;
return {
close: async () => {
closeCalls += 1;
},
};
});
const foreground = await runtime.ensureStatsServerStarted();
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: true });
assert.equal(closeCalls, 0);
assert.deepEqual(await runtime.ensureStatsServerStarted(), foreground);
assert.equal(startCalls, 1);
await runtime.stopStatsServer();
});
test('background stop leaves a pending foreground-only startup alone', async () => {
const deferred = createDeferred<StatsServer>();
const { runtime } = createRuntimeHarness(() => deferred.promise);
const startup = runtime.ensureStatsServerStarted();
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: true });
deferred.resolve({ close: async () => {} });
assert.deepEqual(await startup, { url: 'http://127.0.0.1:5175', source: 'local' });
await runtime.stopStatsServer();
});
test('a startup requested during shutdown waits and then restarts', async () => {
const closeDeferred = createDeferred<void>();
const firstServer: StatsServer = { close: () => closeDeferred.promise };
const secondServer: StatsServer = { close: async () => {} };
const servers = [firstServer, secondServer];
let startCalls = 0;
const { runtime } = createRuntimeHarness(async () => {
const server = servers[startCalls];
startCalls += 1;
if (!server) throw new Error('unexpected startup attempt');
return server;
});
await runtime.ensureStatsServerStarted();
const shutdown = runtime.stopStatsServer();
const restart = runtime.ensureStatsServerStarted();
assert.equal(startCalls, 1);
closeDeferred.resolve();
await shutdown;
assert.deepEqual(await restart, { url: 'http://127.0.0.1:5175', source: 'local' });
assert.equal(startCalls, 2);
});
test('background stop cancels startup before daemon ownership is published', async () => {
const deferred = createDeferred<StatsServer>();
let closeCalls = 0;
const { runtime, appStateValues } = createRuntimeHarness(() => deferred.promise);
const startup = runtime.ensureBackgroundStatsServerStarted();
const shutdown = runtime.stopBackgroundStatsServer();
deferred.resolve({
close: async () => {
closeCalls += 1;
},
});
await assert.rejects(startup, /startup was cancelled/);
assert.deepEqual(await shutdown, { ok: true, stale: false });
assert.equal(closeCalls, 1);
assert.equal(appStateValues.at(-1), null);
});
+164 -88
View File
@@ -4,7 +4,7 @@ import {
addYomitanNoteViaSearch,
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
} from '../../core/services';
import { startStatsServer } from '../../core/services/stats-server';
import { startStatsServer, type StatsServer } from '../../core/services/stats-server';
import { createLogger } from '../../logger';
import type { ResolvedConfig } from '../../types/config';
import type { AppState } from '../state';
@@ -27,12 +27,6 @@ export function isSelfOwnedBackgroundStatsDaemonState(state: {
return state.pid === process.pid;
}
export function shouldClearAppStateStatsServerOnStop(options: {
hadStatsServer: boolean;
}): boolean {
return options.hadStatsServer;
}
export interface StatsServerRuntimeDeps {
userDataPath: string;
statsDistPath: string;
@@ -62,19 +56,28 @@ export interface StatsServerRuntimeDeps {
isBackgroundStatsServerProcessAlive?: typeof defaultIsBackgroundStatsServerProcessAlive;
verifyBackgroundStatsServerIdentity?: typeof defaultVerifyBackgroundStatsServerIdentity;
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
startServer?: typeof startStatsServer;
}
export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
stopStatsServer: () => void;
stopStatsServer: () => Promise<void>;
ensureStatsServerStarted: ReturnType<typeof createEnsureStatsServerUrlHandler>;
ensureBackgroundStatsServerStarted: () => {
ensureBackgroundStatsServerStarted: () => Promise<{
url: string;
runningInCurrentProcess: boolean;
};
}>;
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
} {
let statsServer: ReturnType<typeof startStatsServer> | null = null;
type LocalStatsServerState =
| { kind: 'stopped' }
| { kind: 'starting'; token: symbol; promise: Promise<void> }
| { kind: 'running'; server: StatsServer }
| { kind: 'stopping'; token: symbol; promise: Promise<void> };
let localStatsServerState: LocalStatsServerState = { kind: 'stopped' };
const pendingBackgroundStarts = new Set<symbol>();
const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json');
const startServer = deps.startServer ?? startStatsServer;
const readDaemonState =
deps.readBackgroundStatsServerState ??
((statePath: string) => defaultReadBackgroundStatsServerState(statePath));
@@ -100,7 +103,7 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
removeDaemonState(statsDaemonStatePath);
return null;
}
if (state.pid === process.pid && !statsServer) {
if (state.pid === process.pid && localStatsServerState.kind !== 'running') {
removeDaemonState(statsDaemonStatePath);
return null;
}
@@ -118,74 +121,134 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
}
}
function stopStatsServer(): void {
if (!statsServer) {
return;
}
statsServer.close();
statsServer = null;
if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) {
deps.setAppStateStatsServer(null);
}
clearOwnedBackgroundStatsDaemonState();
}
const startLocalStatsServer = (): void => {
const buildStatsServerConfig = (): Parameters<typeof startStatsServer>[0] => {
const tracker = deps.getImmersionTracker();
if (!tracker) {
throw new Error('Immersion tracker failed to initialize.');
}
if (!statsServer) {
const yomitanDeps = {
getYomitanExt: () => deps.getYomitanExt(),
getYomitanSession: () => deps.getYomitanSession(),
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
setYomitanParserWindow: (w: BrowserWindow | null) => {
deps.setYomitanParserWindow(w);
},
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
deps.setYomitanParserReadyPromise(p);
},
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
deps.setYomitanParserInitPromise(p);
},
};
const yomitanLogger = createLogger('main:yomitan-stats');
statsServer = startStatsServer({
port: deps.getResolvedConfig().stats.serverPort,
staticDir: deps.statsDistPath,
tracker,
knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'),
mpvSocketPath: deps.getMpvSocketPath(),
getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect,
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
getSecondarySubtitleLanguages: () =>
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
anilistRateLimiter: deps.getAnilistRateLimiter(),
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
addYomitanNote: async (word: string) => {
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
deck: ankiConnectConfig.deck,
});
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
if (result.noteId && result.duplicateNoteIds.length > 0) {
deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds);
}
return result.noteId;
},
});
deps.setAppStateStatsServer(statsServer);
}
deps.setAppStateStatsServer(statsServer);
const yomitanDeps = {
getYomitanExt: () => deps.getYomitanExt(),
getYomitanSession: () => deps.getYomitanSession(),
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
setYomitanParserWindow: (w: BrowserWindow | null) => {
deps.setYomitanParserWindow(w);
},
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
deps.setYomitanParserReadyPromise(p);
},
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
deps.setYomitanParserInitPromise(p);
},
};
const yomitanLogger = createLogger('main:yomitan-stats');
return {
port: deps.getResolvedConfig().stats.serverPort,
staticDir: deps.statsDistPath,
tracker,
knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'),
mpvSocketPath: deps.getMpvSocketPath(),
getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect,
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
getSecondarySubtitleLanguages: () =>
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
anilistRateLimiter: deps.getAnilistRateLimiter(),
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
addYomitanNote: async (word: string) => {
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
deck: ankiConnectConfig.deck,
});
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
if (result.noteId && result.duplicateNoteIds.length > 0) {
deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds);
}
return result.noteId;
},
};
};
const beginLocalStatsServerStartup = (): Promise<void> => {
const token = Symbol('stats-server-startup');
const promise = startServer(buildStatsServerConfig())
.then(async (server) => {
const state = localStatsServerState;
if (state.kind !== 'starting' || state.token !== token) {
await server.close();
throw new Error('Stats server startup was cancelled.');
}
localStatsServerState = { kind: 'running', server };
deps.setAppStateStatsServer(server);
})
.catch((error: unknown) => {
const state = localStatsServerState;
if (state.kind === 'starting' && state.token === token) {
localStatsServerState = { kind: 'stopped' };
deps.setAppStateStatsServer(null);
}
throw error;
});
localStatsServerState = { kind: 'starting', token, promise };
return promise;
};
const startLocalStatsServer = async (): Promise<void> => {
while (localStatsServerState.kind === 'stopping') {
await localStatsServerState.promise;
}
if (localStatsServerState.kind === 'running') {
deps.setAppStateStatsServer(localStatsServerState.server);
return;
}
if (localStatsServerState.kind === 'starting') {
await localStatsServerState.promise;
return;
}
await beginLocalStatsServerStartup();
};
function stopStatsServer(): Promise<void> {
const state = localStatsServerState;
if (state.kind === 'stopped') {
deps.setAppStateStatsServer(null);
clearOwnedBackgroundStatsDaemonState();
return Promise.resolve();
}
if (state.kind === 'stopping') {
return state.promise;
}
const token = Symbol('stats-server-shutdown');
const promise = Promise.resolve()
.then(async () => {
if (state.kind === 'starting') {
try {
await state.promise;
} catch {
// Startup owns cleanup of a server that finishes binding after cancellation.
}
return;
}
await state.server.close();
})
.finally(() => {
const current = localStatsServerState;
if (current.kind === 'stopping' && current.token === token) {
localStatsServerState = { kind: 'stopped' };
}
deps.setAppStateStatsServer(null);
clearOwnedBackgroundStatsDaemonState();
});
localStatsServerState = { kind: 'stopping', token, promise };
deps.setAppStateStatsServer(null);
return promise;
}
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
currentPid: process.pid,
readBackgroundState: () => readDaemonState(statsDaemonStatePath),
@@ -193,15 +256,15 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
removeDaemonState(statsDaemonStatePath);
},
isProcessAlive: (pid) => isDaemonAlive(pid),
hasLocalStatsServer: () => statsServer !== null,
hasLocalStatsServer: () => localStatsServerState.kind === 'running',
startLocalStatsServer,
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
});
const ensureBackgroundStatsServerStarted = (): {
const ensureBackgroundStatsServerStarted = async (): Promise<{
url: string;
runningInCurrentProcess: boolean;
} => {
}> => {
const liveDaemon = readLiveBackgroundStatsDaemonState();
if (liveDaemon && liveDaemon.pid !== process.pid) {
return {
@@ -217,27 +280,40 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
deps.setStatsStartupInProgress(false);
}
const port = deps.getResolvedConfig().stats.serverPort;
const result = ensureStatsServerStarted();
if (result.source === 'local') {
writeBackgroundStatsServerState(statsDaemonStatePath, {
pid: process.pid,
port,
startedAtMs: Date.now(),
});
const request = Symbol('background-stats-startup');
pendingBackgroundStarts.add(request);
try {
const port = deps.getResolvedConfig().stats.serverPort;
const result = await ensureStatsServerStarted();
if (result.source === 'local') {
if (localStatsServerState.kind !== 'running') {
throw new Error('Stats server startup was cancelled.');
}
writeBackgroundStatsServerState(statsDaemonStatePath, {
pid: process.pid,
port,
startedAtMs: Date.now(),
});
}
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
} finally {
pendingBackgroundStarts.delete(request);
}
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
};
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
const state = readDaemonState(statsDaemonStatePath);
if (!state) {
if (pendingBackgroundStarts.size > 0) {
await stopStatsServer();
return { ok: true, stale: false };
}
removeDaemonState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
removeDaemonState(statsDaemonStatePath);
return { ok: true, stale: true };
await stopStatsServer();
return { ok: true, stale: false };
}
if (!isDaemonAlive(state.pid)) {
removeDaemonState(statsDaemonStatePath);