mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
fix(stats): harden server lifecycle and verify compiled runtime (#261)
This commit is contained in:
@@ -19,6 +19,23 @@ test('package scripts expose a sharded maintained source coverage lane with lcov
|
||||
);
|
||||
});
|
||||
|
||||
test('source and coverage scripts discover the same maintained source lane', () => {
|
||||
const sourceLane = packageJson.scripts['test:src']?.match(/run-test-lane\.mjs\s+([^\s]+)/)?.[1];
|
||||
const coverageLane = packageJson.scripts['test:coverage:src']?.match(
|
||||
/run-coverage-lane\.ts\s+([^\s]+)/,
|
||||
)?.[1];
|
||||
|
||||
assert.equal(sourceLane, 'bun-src-full');
|
||||
assert.equal(coverageLane, sourceLane);
|
||||
});
|
||||
|
||||
test('environment suite owns launcher smoke execution', () => {
|
||||
assert.match(
|
||||
packageJson.scripts['test:env'] ?? '',
|
||||
/^bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ci delegates its gate instead of duplicating quality steps', () => {
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
|
||||
@@ -5,7 +5,11 @@ import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { createStatsApp, startStatsServer } from '../stats-server.js';
|
||||
import {
|
||||
createStatsApp,
|
||||
startNodeHttpServer,
|
||||
startStatsServerWithRuntime,
|
||||
} from '../stats-server.js';
|
||||
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
||||
import {
|
||||
clearRetimedSecondarySubtitleCache,
|
||||
@@ -3995,102 +3999,133 @@ Aligned English subtitle
|
||||
assert.equal(ensureCalls, 1);
|
||||
});
|
||||
|
||||
it('starts the stats server with Bun.serve', () => {
|
||||
type BunRuntime = {
|
||||
Bun: {
|
||||
serve: (options: { fetch: unknown; port: number; hostname: string }) => {
|
||||
stop: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const bun = globalThis as typeof globalThis & BunRuntime;
|
||||
const originalServe = bun.Bun.serve;
|
||||
let servedWith: { fetch: unknown; port: number; hostname: string } | null = null;
|
||||
it('starts and stops the stats server with Bun.serve', async () => {
|
||||
const servedOptions: Array<{ fetch: unknown; port: number; hostname: string }> = [];
|
||||
let stopCalls = 0;
|
||||
|
||||
bun.Bun.serve = (options: { fetch: unknown; port: number; hostname: string }) => {
|
||||
servedWith = options;
|
||||
return {
|
||||
stop: () => {
|
||||
stopCalls += 1;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const server = startStatsServer({
|
||||
const server = await startStatsServerWithRuntime(
|
||||
{
|
||||
port: 3210,
|
||||
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-start-')),
|
||||
tracker: createMockTracker(),
|
||||
});
|
||||
},
|
||||
{
|
||||
bunServe: (options) => {
|
||||
servedOptions.push(options);
|
||||
return {
|
||||
stop: () => {
|
||||
stopCalls += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (servedWith === null) {
|
||||
throw new Error('expected Bun.serve to be called');
|
||||
}
|
||||
|
||||
const servedOptions = servedWith as {
|
||||
fetch: unknown;
|
||||
port: number;
|
||||
hostname: string;
|
||||
};
|
||||
assert.equal(servedOptions.port, 3210);
|
||||
assert.equal(servedOptions.hostname, '127.0.0.1');
|
||||
assert.equal(typeof servedOptions.fetch, 'function');
|
||||
|
||||
server.close();
|
||||
assert.equal(stopCalls, 1);
|
||||
} finally {
|
||||
bun.Bun.serve = originalServe;
|
||||
const servedWith = servedOptions[0];
|
||||
if (!servedWith) {
|
||||
throw new Error('expected Bun.serve to be called');
|
||||
}
|
||||
|
||||
assert.equal(servedWith.port, 3210);
|
||||
assert.equal(servedWith.hostname, '127.0.0.1');
|
||||
assert.equal(typeof servedWith.fetch, 'function');
|
||||
|
||||
await Promise.all([server.close(), server.close()]);
|
||||
assert.equal(stopCalls, 1);
|
||||
});
|
||||
|
||||
it('falls back to node:http when Bun.serve is unavailable', () => {
|
||||
type BunRuntime = {
|
||||
Bun: {
|
||||
serve?: (options: { fetch: unknown; port: number; hostname: string }) => {
|
||||
stop: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const bun = globalThis as typeof globalThis & BunRuntime;
|
||||
const originalServe = bun.Bun.serve;
|
||||
const originalCreateServer = http.createServer;
|
||||
let listenedWith: { port: number; hostname: string } | null = null;
|
||||
it('waits for node:http listening and converts startup errors into rejections', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const listeningServer = http.createServer();
|
||||
let closeCalls = 0;
|
||||
bun.Bun.serve = undefined;
|
||||
(
|
||||
http as typeof http & {
|
||||
createServer: typeof http.createServer;
|
||||
}
|
||||
).createServer = (() =>
|
||||
({
|
||||
listen: (port: number, hostname: string) => {
|
||||
listenedWith = { port, hostname };
|
||||
},
|
||||
close: () => {
|
||||
Object.defineProperties(listeningServer, {
|
||||
listen: {
|
||||
value: () => listeningServer,
|
||||
},
|
||||
close: {
|
||||
value: (callback?: (error?: Error) => void) => {
|
||||
closeCalls += 1;
|
||||
callback?.();
|
||||
return listeningServer;
|
||||
},
|
||||
}) as unknown as ReturnType<typeof http.createServer>) as typeof http.createServer;
|
||||
},
|
||||
});
|
||||
|
||||
let startupSettled = false;
|
||||
const startup = startNodeHttpServer(
|
||||
app,
|
||||
{
|
||||
port: 3210,
|
||||
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-events-')),
|
||||
tracker: createMockTracker(),
|
||||
},
|
||||
() => listeningServer,
|
||||
);
|
||||
void startup.finally(() => {
|
||||
startupSettled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(startupSettled, false);
|
||||
|
||||
listeningServer.emit('listening');
|
||||
const handle = await startup;
|
||||
await Promise.all([handle.close(), handle.close()]);
|
||||
assert.equal(closeCalls, 1);
|
||||
|
||||
const failingServer = http.createServer();
|
||||
Object.defineProperty(failingServer, 'listen', {
|
||||
value: () => failingServer,
|
||||
});
|
||||
const failedStartup = startNodeHttpServer(
|
||||
app,
|
||||
{
|
||||
port: 3210,
|
||||
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-error-')),
|
||||
tracker: createMockTracker(),
|
||||
},
|
||||
() => failingServer,
|
||||
);
|
||||
failingServer.emit('error', Object.assign(new Error('address in use'), { code: 'EADDRINUSE' }));
|
||||
await assert.rejects(
|
||||
failedStartup,
|
||||
(error: NodeJS.ErrnoException) => error.code === 'EADDRINUSE',
|
||||
);
|
||||
});
|
||||
|
||||
it('starts, rejects address conflicts, and stops through real node:http sockets', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const server = await startNodeHttpServer(app, {
|
||||
port: 0,
|
||||
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-')),
|
||||
tracker: createMockTracker(),
|
||||
});
|
||||
await Promise.all([server.close(), server.close()]);
|
||||
|
||||
const blocker = http.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once('error', reject);
|
||||
blocker.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = blocker.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('expected blocker to listen on a TCP port');
|
||||
}
|
||||
|
||||
try {
|
||||
const server = startStatsServer({
|
||||
port: 0,
|
||||
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-')),
|
||||
tracker: createMockTracker(),
|
||||
});
|
||||
|
||||
assert.deepEqual(listenedWith, { port: 0, hostname: '127.0.0.1' });
|
||||
server.close();
|
||||
assert.equal(closeCalls, 1);
|
||||
await assert.rejects(
|
||||
startNodeHttpServer(app, {
|
||||
port: address.port,
|
||||
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-error-')),
|
||||
tracker: createMockTracker(),
|
||||
}),
|
||||
(error: NodeJS.ErrnoException) => error.code === 'EADDRINUSE',
|
||||
);
|
||||
} finally {
|
||||
bun.Bun.serve = originalServe;
|
||||
(
|
||||
http as typeof http & {
|
||||
createServer: typeof http.createServer;
|
||||
}
|
||||
).createServer = originalCreateServer;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -406,6 +406,17 @@ test('handleCliCommand ensures background stats server for second-instance --sta
|
||||
assert.equal(ensured.length, 1);
|
||||
});
|
||||
|
||||
test('handleCliCommand reports unexpected background stats startup failures', async () => {
|
||||
const startup = Promise.reject(new Error('startup unavailable'));
|
||||
const { deps, calls, osd } = createDeps({ ensureBackgroundStatsServer: () => startup });
|
||||
|
||||
handleCliCommand(makeArgs({ start: true, background: true }), 'initial', deps);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.ok(calls.includes('error:ensureBackgroundStatsServer failed:'));
|
||||
assert.ok(osd.includes('Stats server startup failed: startup unavailable'));
|
||||
});
|
||||
|
||||
test('handleCliCommand does not ensure background stats server for foreground --start', () => {
|
||||
const ensured: number[] = [];
|
||||
const { deps } = createDeps({
|
||||
|
||||
@@ -107,7 +107,7 @@ export interface CliCommandServiceDeps {
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
source: CliCommandSource;
|
||||
}) => Promise<void>;
|
||||
ensureBackgroundStatsServer?: () => void;
|
||||
ensureBackgroundStatsServer?: () => Promise<void> | void;
|
||||
printHelp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
@@ -188,7 +188,7 @@ interface AnilistCliRuntime {
|
||||
interface AppCliRuntime {
|
||||
stop: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
ensureBackgroundStatsServer?: () => void;
|
||||
ensureBackgroundStatsServer?: () => Promise<void> | void;
|
||||
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
||||
@@ -400,7 +400,14 @@ export function handleCliCommand(
|
||||
}
|
||||
|
||||
if (args.start && args.background) {
|
||||
deps.ensureBackgroundStatsServer?.();
|
||||
runAsyncWithOsd(
|
||||
async () => {
|
||||
await deps.ensureBackgroundStatsServer?.();
|
||||
},
|
||||
deps,
|
||||
'ensureBackgroundStatsServer',
|
||||
'Stats server startup failed',
|
||||
);
|
||||
}
|
||||
|
||||
if (args.sessionAction) {
|
||||
|
||||
@@ -6,7 +6,9 @@ import { dispatchSessionAction, type SessionActionExecutorDeps } from './session
|
||||
function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
const deps: SessionActionExecutorDeps = {
|
||||
toggleStatsOverlay: () => calls.push('stats'),
|
||||
toggleStatsOverlay: () => {
|
||||
calls.push('stats');
|
||||
},
|
||||
toggleVisibleOverlay: () => calls.push('visible'),
|
||||
copyCurrentSubtitle: () => calls.push('copy'),
|
||||
copySubtitleCount: (count) => calls.push(`copy:${count}`),
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SessionActionId } from '../../types/session-bindings';
|
||||
import type { SessionActionDispatchRequest } from '../../types/runtime';
|
||||
|
||||
export interface SessionActionExecutorDeps {
|
||||
toggleStatsOverlay: () => void;
|
||||
toggleStatsOverlay: () => Promise<void> | void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
copySubtitleCount: (count: number) => void;
|
||||
@@ -50,7 +50,7 @@ export async function dispatchSessionAction(
|
||||
): Promise<void> {
|
||||
switch (request.actionId) {
|
||||
case 'toggleStatsOverlay':
|
||||
deps.toggleStatsOverlay();
|
||||
await deps.toggleStatsOverlay();
|
||||
return;
|
||||
case 'toggleVisibleOverlay':
|
||||
deps.toggleVisibleOverlay();
|
||||
|
||||
@@ -50,8 +50,26 @@ async function writeFetchResponse(res: ServerResponse, response: Response): Prom
|
||||
res.end(Buffer.from(await response.arrayBuffer()));
|
||||
}
|
||||
|
||||
function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } {
|
||||
const server = http.createServer((req, res) => {
|
||||
export interface StatsServer {
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SHUTDOWN_GRACE_MS = 1_000;
|
||||
|
||||
type BunServe = (options: {
|
||||
fetch: (typeof Hono.prototype)['fetch'];
|
||||
port: number;
|
||||
hostname: string;
|
||||
}) => {
|
||||
stop: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function startNodeHttpServer(
|
||||
app: Hono,
|
||||
config: StatsServerConfig,
|
||||
createServer: (listener: http.RequestListener) => http.Server = http.createServer,
|
||||
): Promise<StatsServer> {
|
||||
const server = createServer((req, res) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await writeFetchResponse(res, await app.fetch(toFetchRequest(req)));
|
||||
@@ -61,12 +79,33 @@ function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: ()
|
||||
}
|
||||
})();
|
||||
});
|
||||
server.listen(config.port, '127.0.0.1');
|
||||
return {
|
||||
close: () => {
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleStartupError = (error: Error): void => {
|
||||
server.removeListener('listening', handleListening);
|
||||
reject(error);
|
||||
};
|
||||
const handleListening = (): void => {
|
||||
server.removeListener('error', handleStartupError);
|
||||
let closePromise: Promise<void> | null = null;
|
||||
resolve({
|
||||
close: () => {
|
||||
closePromise ??= new Promise<void>((closeResolve, closeReject) => {
|
||||
const forceClose = setTimeout(() => server.closeAllConnections(), SHUTDOWN_GRACE_MS);
|
||||
server.close((error) => {
|
||||
clearTimeout(forceClose);
|
||||
if (error) closeReject(error);
|
||||
else closeResolve();
|
||||
});
|
||||
});
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
server.once('error', handleStartupError);
|
||||
server.once('listening', handleListening);
|
||||
server.listen(config.port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
export interface StatsServerConfig {
|
||||
@@ -125,7 +164,10 @@ export function createStatsApp(
|
||||
return app;
|
||||
}
|
||||
|
||||
export function startStatsServer(config: StatsServerConfig): { close: () => void } {
|
||||
export async function startStatsServerWithRuntime(
|
||||
config: StatsServerConfig,
|
||||
runtime: { bunServe: BunServe | null },
|
||||
): Promise<StatsServer> {
|
||||
const app = createStatsApp(config.tracker, {
|
||||
staticDir: config.staticDir,
|
||||
knownWordCachePath: config.knownWordCachePath,
|
||||
@@ -144,20 +186,26 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
||||
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
|
||||
});
|
||||
|
||||
const bunRuntime = globalThis as typeof globalThis & {
|
||||
Bun?: {
|
||||
serve?: (options: { fetch: (typeof app)['fetch']; port: number; hostname: string }) => {
|
||||
stop: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
if (bunRuntime.Bun?.serve) {
|
||||
const server = bunRuntime.Bun.serve({
|
||||
if (runtime.bunServe) {
|
||||
const server = runtime.bunServe({
|
||||
fetch: app.fetch,
|
||||
port: config.port,
|
||||
hostname: '127.0.0.1',
|
||||
});
|
||||
return { close: () => server.stop() };
|
||||
let closePromise: Promise<void> | null = null;
|
||||
return Promise.resolve({
|
||||
close: () => {
|
||||
closePromise ??= Promise.resolve().then(() => server.stop());
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
}
|
||||
return startNodeHttpServer(app, config);
|
||||
}
|
||||
|
||||
export function startStatsServer(config: StatsServerConfig): Promise<StatsServer> {
|
||||
const bunRuntime = globalThis as typeof globalThis & {
|
||||
Bun?: { serve?: BunServe };
|
||||
};
|
||||
return startStatsServerWithRuntime(config, { bunServe: bunRuntime.Bun?.serve ?? null });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BrowserWindow, dialog, ipcMain } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '../../logger.js';
|
||||
import type { WindowGeometry } from '../../types.js';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
||||
import {
|
||||
@@ -26,9 +27,11 @@ import {
|
||||
} from './stats-window-layer.js';
|
||||
|
||||
let statsWindow: BrowserWindow | null = null;
|
||||
let statsWindowGeneration = 0;
|
||||
let toggleRegistered = false;
|
||||
let nativeDialogLayerRegistered = false;
|
||||
const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState();
|
||||
const logger = createLogger('main:stats-window');
|
||||
|
||||
export interface StatsWindowOptions {
|
||||
/** Absolute path to stats/dist/ directory */
|
||||
@@ -36,7 +39,9 @@ export interface StatsWindowOptions {
|
||||
/** Absolute path to the compiled preload-stats.js */
|
||||
preloadPath: string;
|
||||
/** Resolve the active stats API base URL */
|
||||
getApiBaseUrl?: () => string;
|
||||
getApiBaseUrl?: () => Promise<string> | string;
|
||||
/** Report server startup failure through the configured notification surface. */
|
||||
onStartupError?: (error: unknown) => void;
|
||||
/** Resolve the active stats toggle key from config */
|
||||
getToggleKey: () => string;
|
||||
/** Resolve the tracked overlay/mpv bounds */
|
||||
@@ -179,8 +184,16 @@ function registerStatsNativeDialogLayerHandlers(): void {
|
||||
* Toggle the stats overlay window: create on first call, then show/hide.
|
||||
* The React app stays mounted across toggles — state is preserved.
|
||||
*/
|
||||
export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
export async function toggleStatsOverlay(options: StatsWindowOptions): Promise<void> {
|
||||
if (!statsWindow) {
|
||||
const generation = statsWindowGeneration;
|
||||
const apiBaseUrl = await Promise.resolve()
|
||||
.then(() => options.getApiBaseUrl?.())
|
||||
.catch((error: unknown) => {
|
||||
options.onStartupError?.(error);
|
||||
throw error;
|
||||
});
|
||||
if (generation !== statsWindowGeneration || statsWindow) return;
|
||||
statsWindow = new BrowserWindow(
|
||||
buildStatsWindowOptions({
|
||||
preloadPath: options.preloadPath,
|
||||
@@ -195,7 +208,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
});
|
||||
|
||||
const indexPath = path.join(options.staticDir, 'index.html');
|
||||
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(options.getApiBaseUrl?.()));
|
||||
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(apiBaseUrl));
|
||||
|
||||
statsWindow.on('closed', () => {
|
||||
options.onVisibilityChanged?.(false);
|
||||
@@ -243,7 +256,9 @@ export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
|
||||
if (toggleRegistered) return;
|
||||
toggleRegistered = true;
|
||||
ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => {
|
||||
toggleStatsOverlay(options);
|
||||
void toggleStatsOverlay(options).catch((error: unknown) => {
|
||||
logger.error('Failed to open stats overlay:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,6 +267,7 @@ export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
|
||||
* Call during app quit.
|
||||
*/
|
||||
export function destroyStatsWindow(): void {
|
||||
statsWindowGeneration += 1;
|
||||
if (statsWindow && !statsWindow.isDestroyed()) {
|
||||
statsWindow.destroy();
|
||||
statsWindow = null;
|
||||
|
||||
+31
-10
@@ -421,6 +421,7 @@ import {
|
||||
writeStatsCliCommandResponse,
|
||||
} from './main/runtime/stats-cli-command';
|
||||
import { createStatsServerRuntime } from './main/runtime/stats-server-runtime';
|
||||
import { createForceQuitHandler } from './main/runtime/app-lifecycle-actions';
|
||||
import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos';
|
||||
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
|
||||
import {
|
||||
@@ -1010,11 +1011,17 @@ function requestAppQuit(): void {
|
||||
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
|
||||
appState.yomitanSettingsWindow = null;
|
||||
destroyStatsWindow();
|
||||
stopStatsServer();
|
||||
void stopStatsServer().catch((error: unknown) => {
|
||||
logger.warn('Failed to stop stats server while quitting.', error);
|
||||
});
|
||||
if (!forceQuitTimer) {
|
||||
forceQuitTimer = setTimeout(() => {
|
||||
logger.warn('App quit timed out; forcing process exit.');
|
||||
app.exit(0);
|
||||
void createForceQuitHandler({
|
||||
destroyImmersionTracker: () => appState.immersionTracker?.destroy(),
|
||||
logError: (error) => logger.error('Failed to finalize stats before forced exit.', error),
|
||||
exit: () => app.exit(0),
|
||||
})();
|
||||
}, 2000);
|
||||
}
|
||||
app.quit();
|
||||
@@ -4005,8 +4012,8 @@ const {
|
||||
},
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
stopStatsServer: () => stopStatsServer(),
|
||||
clearImmersionTracker: () => {
|
||||
stopStatsServer();
|
||||
appState.statsServer = null;
|
||||
appState.immersionTracker = null;
|
||||
},
|
||||
@@ -4095,7 +4102,9 @@ const immersionTrackerStartupMainDeps: Parameters<
|
||||
const trackerHasChanged =
|
||||
appState.immersionTracker !== null && appState.immersionTracker !== tracker;
|
||||
if (trackerHasChanged && appState.statsServer) {
|
||||
stopStatsServer();
|
||||
void stopStatsServer().catch((error: unknown) => {
|
||||
logger.warn('Failed to stop stats server while replacing immersion tracker.', error);
|
||||
});
|
||||
appState.statsServer = null;
|
||||
}
|
||||
|
||||
@@ -4106,7 +4115,9 @@ const immersionTrackerStartupMainDeps: Parameters<
|
||||
if (!appState.statsServer) {
|
||||
const config = configService.getConfig();
|
||||
if (config.stats.autoStartServer) {
|
||||
ensureStatsServerStarted();
|
||||
void ensureStatsServerStarted().catch((error: unknown) => {
|
||||
logger.warn('Failed to auto-start stats server.', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4114,7 +4125,12 @@ const immersionTrackerStartupMainDeps: Parameters<
|
||||
registerStatsOverlayToggle({
|
||||
staticDir: statsDistPath,
|
||||
preloadPath: statsPreloadPath,
|
||||
getApiBaseUrl: () => ensureStatsServerStarted().url,
|
||||
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
|
||||
onStartupError: (error) =>
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(
|
||||
`Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ title: 'Stats' },
|
||||
),
|
||||
getToggleKey: () => configService.getConfig().stats.toggleKey,
|
||||
resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(),
|
||||
onVisibilityChanged: (visible) => {
|
||||
@@ -4196,7 +4212,7 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
|
||||
await createMecabTokenizerAndCheck();
|
||||
},
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted().url,
|
||||
ensureStatsServerStarted: async () => (await statsStartupRuntime.ensureStatsServerStarted()).url,
|
||||
ensureBackgroundStatsServerStarted: () =>
|
||||
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
|
||||
stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(),
|
||||
@@ -5488,11 +5504,16 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand
|
||||
|
||||
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
||||
await dispatchSessionActionCore(request, {
|
||||
toggleStatsOverlay: () =>
|
||||
toggleStatsOverlayWindow({
|
||||
toggleStatsOverlay: async () =>
|
||||
await toggleStatsOverlayWindow({
|
||||
staticDir: statsDistPath,
|
||||
preloadPath: statsPreloadPath,
|
||||
getApiBaseUrl: () => ensureStatsServerStarted().url,
|
||||
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
|
||||
onStartupError: (error) =>
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(
|
||||
`Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ title: 'Stats' },
|
||||
),
|
||||
getToggleKey: () => configService.getConfig().stats.toggleKey,
|
||||
resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(),
|
||||
onVisibilityChanged: (visible) => {
|
||||
|
||||
@@ -433,11 +433,11 @@ test('warm tokenization release can signal readiness before the first subtitle a
|
||||
|
||||
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
||||
const source = readSource('src/main/runtime/stats-server-runtime.ts');
|
||||
const startStatsServerBlock = source.match(
|
||||
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||
const statsServerConfigBlock = source.match(
|
||||
/const buildStatsServerConfig[\s\S]*?return \{(?<body>[\s\S]*?)\n \};\n \};/,
|
||||
)?.groups?.body;
|
||||
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
||||
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
const addYomitanNoteBlock = statsServerConfigBlock?.match(
|
||||
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(addYomitanNoteBlock);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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' };
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@ export interface AppState {
|
||||
anilistSetupPageOpened: boolean;
|
||||
anilistRetryQueueState: AnilistRetryQueueState;
|
||||
firstRunSetupCompleted: boolean;
|
||||
statsServer: { close: () => void } | null;
|
||||
statsServer: { close: () => Promise<void> } | null;
|
||||
statsStartupInProgress: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ test('quality gate checkout does not persist GitHub credentials', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('quality gate installs Lua and runs the environment suite before coverage', () => {
|
||||
test('quality gate runs non-covered source suites and lets coverage gate the src lane', () => {
|
||||
assert.match(qualityGateWorkflow, /name: Install Lua/);
|
||||
assert.match(
|
||||
qualityGateWorkflow,
|
||||
@@ -32,7 +32,18 @@ test('quality gate installs Lua and runs the environment suite before coverage',
|
||||
assert.match(qualityGateWorkflow, /apt-get\s+"\$\{apt_sources\[@\]\}"\s+install\s+-y\s+lua5\.4/);
|
||||
assert.match(
|
||||
qualityGateWorkflow,
|
||||
/Test suite \(source\)\n\s*run: bun run test:fast\n\s*\n\s*- name: Environment suite\n\s*run: bun run test:env\n\s*\n\s*- name: Coverage suite \(maintained source lane\)/,
|
||||
/Launcher unit and script suites\n\s*run: bun run test:launcher:unit:src && bun run test:scripts/,
|
||||
);
|
||||
assert.doesNotMatch(qualityGateWorkflow, /bun run test:fast/);
|
||||
assert.match(qualityGateWorkflow, /run: bun run test:coverage:src/);
|
||||
});
|
||||
|
||||
test('quality gate runs launcher smoke once through the environment suite and keeps artifacts', () => {
|
||||
assert.match(qualityGateWorkflow, /name: Environment suite\n\s*run: bun run test:env/);
|
||||
assert.doesNotMatch(qualityGateWorkflow, /run: bun run test:launcher:smoke:src/);
|
||||
assert.match(
|
||||
qualityGateWorkflow,
|
||||
/name: Upload launcher smoke artifacts \(on failure\)[\s\S]*?if: failure\(\)[\s\S]*?path: \.tmp\/launcher-smoke\/\*\*/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -42,6 +53,13 @@ test('quality gate uploads maintained source coverage', () => {
|
||||
assert.match(qualityGateWorkflow, /path: coverage\/test-src\/lcov\.info/);
|
||||
});
|
||||
|
||||
test('quality gate preserves stats, compiled SQLite, and dist runtime checks', () => {
|
||||
assert.match(qualityGateWorkflow, /run: bun run test:stats/);
|
||||
assert.match(qualityGateWorkflow, /run: bun run build/);
|
||||
assert.match(qualityGateWorkflow, /run: bun run test:immersion:sqlite:dist/);
|
||||
assert.match(qualityGateWorkflow, /run: bun run test:smoke:dist/);
|
||||
});
|
||||
|
||||
test('quality gate keeps pull request changelog enforcement event-aware', () => {
|
||||
assert.match(qualityGateWorkflow, /bun run changelog:lint/);
|
||||
assert.match(qualityGateWorkflow, /if: github\.event_name == 'pull_request'/);
|
||||
|
||||
+28
-20
@@ -127,7 +127,8 @@ const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
|
||||
const wordHelperScriptPath = path.join(__dirname, 'stats-word-helper.js');
|
||||
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
let statsServer: ReturnType<typeof startStatsServer> | null = null;
|
||||
let statsServer: Awaited<ReturnType<typeof startStatsServer>> | null = null;
|
||||
let shutdownPromise: Promise<void> | null = null;
|
||||
|
||||
function writeFailureResponse(message: string): void {
|
||||
if (!responsePath) return;
|
||||
@@ -147,25 +148,32 @@ function clearOwnedState(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function shutdown(code = 0): void {
|
||||
try {
|
||||
statsServer?.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
statsServer = null;
|
||||
try {
|
||||
tracker?.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
tracker = null;
|
||||
clearOwnedState();
|
||||
process.exit(code);
|
||||
function shutdown(code = 0): Promise<void> {
|
||||
shutdownPromise ??= (async () => {
|
||||
try {
|
||||
await statsServer?.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
statsServer = null;
|
||||
try {
|
||||
await tracker?.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
tracker = null;
|
||||
clearOwnedState();
|
||||
process.exit(code);
|
||||
})();
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown(0));
|
||||
process.on('SIGTERM', () => shutdown(0));
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown(0);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown(0);
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
@@ -198,7 +206,7 @@ async function main(): Promise<void> {
|
||||
createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art')),
|
||||
);
|
||||
|
||||
statsServer = startStatsServer({
|
||||
statsServer = await startStatsServer({
|
||||
port: config.stats.serverPort,
|
||||
staticDir: statsDistPath,
|
||||
tracker,
|
||||
@@ -237,7 +245,7 @@ async function main(): Promise<void> {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Failed to start stats daemon', message);
|
||||
writeFailureResponse(message);
|
||||
shutdown(1);
|
||||
await shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user