Files
SubMiner/src/main/runtime/stats-server-runtime.ts
T

285 lines
11 KiB
TypeScript

import path from 'node:path';
import type { BrowserWindow } from 'electron';
import {
addYomitanNoteViaSearch,
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
} from '../../core/services';
import { startStatsServer } from '../../core/services/stats-server';
import { createLogger } from '../../logger';
import type { ResolvedConfig } from '../../types/config';
import type { AppState } from '../state';
import {
isBackgroundStatsServerProcessAlive as defaultIsBackgroundStatsServerProcessAlive,
readBackgroundStatsServerState as defaultReadBackgroundStatsServerState,
removeBackgroundStatsServerState as defaultRemoveBackgroundStatsServerState,
resolveBackgroundStatsServerUrl,
verifyBackgroundStatsServerIdentity as defaultVerifyBackgroundStatsServerIdentity,
writeBackgroundStatsServerState,
} from './stats-daemon';
import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
export function isSelfOwnedBackgroundStatsDaemonState(state: {
pid: number;
port?: number;
startedAtMs?: number;
}): boolean {
return state.pid === process.pid;
}
export function shouldClearAppStateStatsServerOnStop(options: {
hadStatsServer: boolean;
}): boolean {
return options.hadStatsServer;
}
export interface StatsServerRuntimeDeps {
userDataPath: string;
statsDistPath: string;
getResolvedConfig: () => ResolvedConfig;
getImmersionTracker: () => AppState['immersionTracker'];
setAppStateStatsServer: (server: AppState['statsServer']) => void;
getMpvSocketPath: () => AppState['mpvSocketPath'];
getYomitanExt: () => AppState['yomitanExt'];
getYomitanSession: () => AppState['yomitanSession'];
getYomitanParserWindow: () => AppState['yomitanParserWindow'];
setYomitanParserWindow: (w: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => AppState['yomitanParserReadyPromise'];
setYomitanParserReadyPromise: (p: Promise<void> | null) => void;
getYomitanParserInitPromise: () => AppState['yomitanParserInitPromise'];
setYomitanParserInitPromise: (p: Promise<boolean> | null) => void;
getYomitanAnkiDeckName: () => Promise<string>;
getAnilistRateLimiter: () => NonNullable<
Parameters<typeof startStatsServer>[0]['anilistRateLimiter']
>;
resolveAnkiNoteId: (noteId: number) => number;
trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void;
resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>;
ensureImmersionTrackerStarted: () => void;
setStatsStartupInProgress: (inProgress: boolean) => void;
readBackgroundStatsServerState?: typeof defaultReadBackgroundStatsServerState;
removeBackgroundStatsServerState?: typeof defaultRemoveBackgroundStatsServerState;
isBackgroundStatsServerProcessAlive?: typeof defaultIsBackgroundStatsServerProcessAlive;
verifyBackgroundStatsServerIdentity?: typeof defaultVerifyBackgroundStatsServerIdentity;
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
}
export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
stopStatsServer: () => void;
ensureStatsServerStarted: ReturnType<typeof createEnsureStatsServerUrlHandler>;
ensureBackgroundStatsServerStarted: () => {
url: string;
runningInCurrentProcess: boolean;
};
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
} {
let statsServer: ReturnType<typeof startStatsServer> | null = null;
const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json');
const readDaemonState =
deps.readBackgroundStatsServerState ??
((statePath: string) => defaultReadBackgroundStatsServerState(statePath));
const removeDaemonState =
deps.removeBackgroundStatsServerState ??
((statePath: string) => defaultRemoveBackgroundStatsServerState(statePath));
const isDaemonAlive =
deps.isBackgroundStatsServerProcessAlive ??
((pid: number) => defaultIsBackgroundStatsServerProcessAlive(pid));
const verifyDaemonIdentity =
deps.verifyBackgroundStatsServerIdentity ??
((pid: number, startedAtMs: number) =>
defaultVerifyBackgroundStatsServerIdentity(pid, startedAtMs));
const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal));
function readLiveBackgroundStatsDaemonState(): {
pid: number;
port: number;
startedAtMs: number;
} | null {
const state = readDaemonState(statsDaemonStatePath);
if (!state) {
removeDaemonState(statsDaemonStatePath);
return null;
}
if (state.pid === process.pid && !statsServer) {
removeDaemonState(statsDaemonStatePath);
return null;
}
if (!isDaemonAlive(state.pid)) {
removeDaemonState(statsDaemonStatePath);
return null;
}
return state;
}
function clearOwnedBackgroundStatsDaemonState(): void {
const state = readDaemonState(statsDaemonStatePath);
if (state?.pid === process.pid) {
removeDaemonState(statsDaemonStatePath);
}
}
function stopStatsServer(): void {
if (!statsServer) {
return;
}
statsServer.close();
statsServer = null;
if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) {
deps.setAppStateStatsServer(null);
}
clearOwnedBackgroundStatsDaemonState();
}
const startLocalStatsServer = (): void => {
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 ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
currentPid: process.pid,
readBackgroundState: () => readDaemonState(statsDaemonStatePath),
removeBackgroundState: () => {
removeDaemonState(statsDaemonStatePath);
},
isProcessAlive: (pid) => isDaemonAlive(pid),
hasLocalStatsServer: () => statsServer !== null,
startLocalStatsServer,
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
});
const ensureBackgroundStatsServerStarted = (): {
url: string;
runningInCurrentProcess: boolean;
} => {
const liveDaemon = readLiveBackgroundStatsDaemonState();
if (liveDaemon && liveDaemon.pid !== process.pid) {
return {
url: resolveBackgroundStatsServerUrl(liveDaemon),
runningInCurrentProcess: false,
};
}
deps.setStatsStartupInProgress(true);
try {
deps.ensureImmersionTrackerStarted();
} finally {
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(),
});
}
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
};
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
const state = readDaemonState(statsDaemonStatePath);
if (!state) {
removeDaemonState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
removeDaemonState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if (!isDaemonAlive(state.pid)) {
removeDaemonState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if (!verifyDaemonIdentity(state.pid, state.startedAtMs)) {
removeDaemonState(statsDaemonStatePath);
return { ok: true, stale: true };
}
try {
killProcess(state.pid, 'SIGTERM');
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
removeDaemonState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if ((error as NodeJS.ErrnoException)?.code === 'EPERM') {
throw new Error(
`Insufficient permissions to stop background stats server (pid ${state.pid}).`,
);
}
throw error;
}
const deadline = Date.now() + 2_000;
while (Date.now() < deadline) {
if (!isDaemonAlive(state.pid)) {
removeDaemonState(statsDaemonStatePath);
return { ok: true, stale: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error('Timed out stopping background stats server.');
};
return {
stopStatsServer,
ensureStatsServerStarted,
ensureBackgroundStatsServerStarted,
stopBackgroundStatsServer,
};
}