refactor(main): extract stats server runtime from main.ts

This commit is contained in:
2026-06-11 23:03:56 -07:00
parent 1a3944aa4f
commit a4edf53d21
3 changed files with 291 additions and 198 deletions
+44 -194
View File
@@ -351,10 +351,8 @@ import {
runStartupBootstrapRuntime, runStartupBootstrapRuntime,
saveJellyfinSubtitleDelay, saveJellyfinSubtitleDelay,
saveSubtitlePosition as saveSubtitlePositionCore, saveSubtitlePosition as saveSubtitlePositionCore,
addYomitanNoteViaSearch,
clearYomitanParserCachesForWindow, clearYomitanParserCachesForWindow,
getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore, getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore,
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
sendMpvCommandRuntime, sendMpvCommandRuntime,
setMpvSubVisibilityRuntime, setMpvSubVisibilityRuntime,
setOverlayDebugVisualizationEnabledRuntime, setOverlayDebugVisualizationEnabledRuntime,
@@ -375,7 +373,6 @@ import { hasHyprlandWindowPlacementBoundsMismatch } from './core/services/hyprla
import { normalizeOverlayWindowBoundsForPlatform } from './core/services/overlay-window-bounds'; import { normalizeOverlayWindowBoundsForPlatform } from './core/services/overlay-window-bounds';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import { startStatsServer } from './core/services/stats-server';
import { import {
destroyStatsWindow, destroyStatsWindow,
promoteStatsOverlayAbovePlayback, promoteStatsOverlayAbovePlayback,
@@ -453,14 +450,7 @@ import {
createRunStatsCliCommandHandler, createRunStatsCliCommandHandler,
writeStatsCliCommandResponse, writeStatsCliCommandResponse,
} from './main/runtime/stats-cli-command'; } from './main/runtime/stats-cli-command';
import { import { createStatsServerRuntime } from './main/runtime/stats-server-runtime';
isBackgroundStatsServerProcessAlive,
readBackgroundStatsServerState,
removeBackgroundStatsServerState,
resolveBackgroundStatsServerUrl,
writeBackgroundStatsServerState,
} from './main/runtime/stats-daemon';
import { createEnsureStatsServerUrlHandler } from './main/runtime/stats-server-routing';
import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos'; import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos';
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
import { import {
@@ -594,7 +584,6 @@ import {
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload'; import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload';
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
import { shouldForceOverrideYomitanAnkiServer } from './main/runtime/yomitan-anki-server';
import { createYomitanAnkiServerSyncRuntime } from './main/runtime/yomitan-anki-server-sync'; import { createYomitanAnkiServerSyncRuntime } from './main/runtime/yomitan-anki-server-sync';
import { import {
type AnilistMediaGuessRuntimeState, type AnilistMediaGuessRuntimeState,
@@ -1010,45 +999,49 @@ const reportFatalError = createFatalErrorReporter({
}); });
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null; let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
let statsServer: ReturnType<typeof startStatsServer> | null = null; const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
const statsDaemonStatePath = path.join(USER_DATA_PATH, 'stats-daemon.json'); const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
const statsServerRuntime = createStatsServerRuntime({
function readLiveBackgroundStatsDaemonState(): { userDataPath: USER_DATA_PATH,
pid: number; statsDistPath,
port: number; getResolvedConfig: () => getResolvedConfig(),
startedAtMs: number; getImmersionTracker: () => appState.immersionTracker,
} | null { setAppStateStatsServer: (server) => {
const state = readBackgroundStatsServerState(statsDaemonStatePath); appState.statsServer = server;
if (!state) { },
removeBackgroundStatsServerState(statsDaemonStatePath); getMpvSocketPath: () => appState.mpvSocketPath,
return null; getYomitanExt: () => appState.yomitanExt,
} getYomitanSession: () => appState.yomitanSession,
if (state.pid === process.pid && !statsServer) { getYomitanParserWindow: () => appState.yomitanParserWindow,
removeBackgroundStatsServerState(statsDaemonStatePath); setYomitanParserWindow: (w) => {
return null; appState.yomitanParserWindow = w;
} },
if (!isBackgroundStatsServerProcessAlive(state.pid)) { getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
removeBackgroundStatsServerState(statsDaemonStatePath); setYomitanParserReadyPromise: (p) => {
return null; appState.yomitanParserReadyPromise = p;
} },
return state; getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
} setYomitanParserInitPromise: (p) => {
appState.yomitanParserInitPromise = p;
function clearOwnedBackgroundStatsDaemonState(): void { },
const state = readBackgroundStatsServerState(statsDaemonStatePath); getYomitanAnkiDeckName: () => getCurrentYomitanAnkiDeckNameForRuntime(),
if (state?.pid === process.pid) { getAnilistRateLimiter: () => anilistRateLimiter,
removeBackgroundStatsServerState(statsDaemonStatePath); resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
} trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => {
} appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
},
function stopStatsServer(): void { resolveSentenceSearchHeadwords: (term) => resolveSentenceSearchHeadwords(term),
if (!statsServer) { ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(),
return; setStatsStartupInProgress: (inProgress) => {
} appState.statsStartupInProgress = inProgress;
statsServer.close(); },
statsServer = null; });
clearOwnedBackgroundStatsDaemonState(); const {
} stopStatsServer,
ensureStatsServerStarted,
ensureBackgroundStatsServerStarted,
stopBackgroundStatsServer,
} = statsServerRuntime;
function requestAppQuit(): void { function requestAppQuit(): void {
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow); destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
@@ -4618,149 +4611,6 @@ const {
}); });
registerProtocolUrlHandlersHandler(); registerProtocolUrlHandlersHandler();
const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
const startLocalStatsServer = (): void => {
const tracker = appState.immersionTracker;
if (!tracker) {
throw new Error('Immersion tracker failed to initialize.');
}
if (!statsServer) {
const yomitanDeps = {
getYomitanExt: () => appState.yomitanExt,
getYomitanSession: () => appState.yomitanSession,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (w: BrowserWindow | null) => {
appState.yomitanParserWindow = w;
},
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
appState.yomitanParserReadyPromise = p;
},
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
appState.yomitanParserInitPromise = p;
},
};
const yomitanLogger = createLogger('main:yomitan-stats');
statsServer = startStatsServer({
port: getResolvedConfig().stats.serverPort,
staticDir: statsDistPath,
tracker,
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
mpvSocketPath: appState.mpvSocketPath,
getAnkiConnectConfig: () => getResolvedConfig().ankiConnect,
getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime,
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
getStatsMiningAlassPath: () => getResolvedConfig().subsync.alass_path,
anilistRateLimiter,
resolveAnkiNoteId: (noteId: number) =>
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
resolveSentenceSearchHeadwords,
addYomitanNote: async (word: string) => {
const ankiConnectConfig = 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) {
appState.ankiIntegration?.trackDuplicateNoteIdsForNote(
result.noteId,
result.duplicateNoteIds,
);
}
return result.noteId;
},
});
appState.statsServer = statsServer;
}
appState.statsServer = statsServer;
};
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
currentPid: process.pid,
readBackgroundState: () => readBackgroundStatsServerState(statsDaemonStatePath),
removeBackgroundState: () => {
removeBackgroundStatsServerState(statsDaemonStatePath);
},
isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(pid),
hasLocalStatsServer: () => statsServer !== null,
startLocalStatsServer,
getConfiguredPort: () => getResolvedConfig().stats.serverPort,
});
const ensureBackgroundStatsServerStarted = (): {
url: string;
runningInCurrentProcess: boolean;
} => {
const liveDaemon = readLiveBackgroundStatsDaemonState();
if (liveDaemon && liveDaemon.pid !== process.pid) {
return {
url: resolveBackgroundStatsServerUrl(liveDaemon),
runningInCurrentProcess: false,
};
}
appState.statsStartupInProgress = true;
try {
ensureImmersionTrackerStarted();
} finally {
appState.statsStartupInProgress = false;
}
const port = 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 = readBackgroundStatsServerState(statsDaemonStatePath);
if (!state) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true };
}
try {
process.kill(state.pid, 'SIGTERM');
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
removeBackgroundStatsServerState(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 (!isBackgroundStatsServerProcessAlive(state.pid)) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error('Timed out stopping background stats server.');
};
const resolveLegacyVocabularyPos = async (row: { const resolveLegacyVocabularyPos = async (row: {
headword: string; headword: string;
word: string; word: string;
+8 -4
View File
@@ -7,6 +7,10 @@ function readMainSource(): string {
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8'); return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
} }
function readSource(relPath: string): string {
return fs.readFileSync(path.join(process.cwd(), relPath), 'utf8');
}
test('manual watched session action starts immersion tracker before marking watched', () => { test('manual watched session action starts immersion tracker before marking watched', () => {
const source = readMainSource(); const source = readMainSource();
const actionBlock = source.match( const actionBlock = source.match(
@@ -346,18 +350,18 @@ test('warm tokenization release can signal readiness before the first subtitle a
}); });
test('stats server Yomitan note creation honors configured Anki server override policy', () => { test('stats server Yomitan note creation honors configured Anki server override policy', () => {
const source = readMainSource(); const source = readSource('src/main/runtime/stats-server-runtime.ts');
const startStatsServerBlock = source.match( const startStatsServerBlock = source.match(
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/, /statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
)?.groups?.body; )?.groups?.body;
const addYomitanNoteBlock = startStatsServerBlock?.match( const addYomitanNoteBlock = startStatsServerBlock?.match(
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/, /addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body; )?.groups?.body;
assert.ok(addYomitanNoteBlock); assert.ok(addYomitanNoteBlock);
assert.match( assert.match(
addYomitanNoteBlock, addYomitanNoteBlock,
/const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/, /const ankiConnectConfig = deps\.getResolvedConfig\(\)\.ankiConnect;/,
); );
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/); assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/); assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
+239
View File
@@ -0,0 +1,239 @@
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,
readBackgroundStatsServerState,
removeBackgroundStatsServerState,
resolveBackgroundStatsServerUrl,
writeBackgroundStatsServerState,
} from './stats-daemon';
import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
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;
}
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');
function readLiveBackgroundStatsDaemonState(): {
pid: number;
port: number;
startedAtMs: number;
} | null {
const state = readBackgroundStatsServerState(statsDaemonStatePath);
if (!state) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return null;
}
if (state.pid === process.pid && !statsServer) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return null;
}
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return null;
}
return state;
}
function clearOwnedBackgroundStatsDaemonState(): void {
const state = readBackgroundStatsServerState(statsDaemonStatePath);
if (state?.pid === process.pid) {
removeBackgroundStatsServerState(statsDaemonStatePath);
}
}
function stopStatsServer(): void {
if (!statsServer) {
return;
}
statsServer.close();
statsServer = 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: () => readBackgroundStatsServerState(statsDaemonStatePath),
removeBackgroundState: () => {
removeBackgroundStatsServerState(statsDaemonStatePath);
},
isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(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 = readBackgroundStatsServerState(statsDaemonStatePath);
if (!state) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true };
}
try {
process.kill(state.pid, 'SIGTERM');
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
removeBackgroundStatsServerState(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 (!isBackgroundStatsServerProcessAlive(state.pid)) {
removeBackgroundStatsServerState(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,
};
}