import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { runAppCommandAttached } from '../mpv.js'; import { sleep } from '../util.js'; import type { LauncherCommandContext } from './context.js'; type StatsCommandResponse = { ok: boolean; url?: string; error?: string; }; type StatsCommandDeps = { createTempDir: (prefix: string) => string; joinPath: (...parts: string[]) => string; runAppCommandAttached: ( appPath: string, appArgs: string[], logLevel: LauncherCommandContext['args']['logLevel'], label: string, ) => Promise; waitForStatsResponse: ( responsePath: string, signal?: AbortSignal, ) => Promise; removeDir: (targetPath: string) => void; }; const STATS_STARTUP_RESPONSE_TIMEOUT_MS = 12_000; type StatsResponseWait = { controller: AbortController; promise: Promise<{ kind: 'response'; response: StatsCommandResponse }>; }; type StatsStartupResult = | { kind: 'response'; response: StatsCommandResponse } | { kind: 'exit'; status: number } | { kind: 'spawn-error'; error: unknown }; const defaultDeps: StatsCommandDeps = { createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)), joinPath: (...parts) => path.join(...parts), runAppCommandAttached: (appPath, appArgs, logLevel, label) => runAppCommandAttached(appPath, appArgs, logLevel, label), waitForStatsResponse: async (responsePath, signal) => { const deadline = Date.now() + STATS_STARTUP_RESPONSE_TIMEOUT_MS; while (Date.now() < deadline) { if (signal?.aborted) { return { ok: false, error: 'Cancelled waiting for stats dashboard startup response.', }; } try { if (fs.existsSync(responsePath)) { return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as StatsCommandResponse; } } catch { // retry until timeout } await sleep(100); } return { ok: false, error: 'Timed out waiting for stats dashboard startup response.', }; }, removeDir: (targetPath) => { fs.rmSync(targetPath, { recursive: true, force: true }); }, }; async function performStartupHandshake( createResponseWait: () => StatsResponseWait, attachedExitPromise: Promise, ): Promise { const responseWait = createResponseWait(); const startupResult = await Promise.race([ responseWait.promise, attachedExitPromise.then( (status) => ({ kind: 'exit' as const, status }), (error) => ({ kind: 'spawn-error' as const, error }), ), ]); if (startupResult.kind === 'spawn-error') { responseWait.controller.abort(); throw startupResult.error; } if (startupResult.kind === 'exit') { if (startupResult.status !== 0) { responseWait.controller.abort(); throw new Error(`Stats app exited before startup response (status ${startupResult.status}).`); } const response = await responseWait.promise.then((result) => result.response); if (!response.ok) { throw new Error(response.error || 'Stats dashboard failed to start.'); } return true; } if (!startupResult.response.ok) { throw new Error(startupResult.response.error || 'Stats dashboard failed to start.'); } const exitStatus = await attachedExitPromise; if (exitStatus !== 0) { throw new Error(`Stats app exited with status ${exitStatus}.`); } return true; } export async function runStatsCommand( context: LauncherCommandContext, deps: Partial = {}, ): Promise { const resolvedDeps: StatsCommandDeps = { ...defaultDeps, ...deps }; const { args, appPath } = context; if (!args.stats || !appPath) { return false; } const tempDir = resolvedDeps.createTempDir('subminer-stats-'); const responsePath = resolvedDeps.joinPath(tempDir, 'response.json'); const createResponseWait = () => { const controller = new AbortController(); return { controller, promise: resolvedDeps .waitForStatsResponse(responsePath, controller.signal) .then((response) => ({ kind: 'response' as const, response })), }; }; try { const forwarded = args.statsCleanup ? ['--stats', '--stats-response-path', responsePath] : args.statsStop ? ['--stats-daemon-stop', '--stats-response-path', responsePath] : args.statsBackground ? ['--stats-daemon-start', '--stats-response-path', responsePath] : ['--stats', '--stats-response-path', responsePath]; if (args.statsCleanup) { forwarded.push('--stats-cleanup'); } if (args.statsCleanupVocab) { forwarded.push('--stats-cleanup-vocab'); } if (args.statsCleanupLifetime) { forwarded.push('--stats-cleanup-lifetime'); } if (args.logLevel !== 'info') { forwarded.push('--log-level', args.logLevel); } const attachedExitPromise = resolvedDeps.runAppCommandAttached( appPath, forwarded, args.logLevel, 'stats', ); if (args.statsStop) { const status = await attachedExitPromise; if (status !== 0) { throw new Error(`Stats app exited with status ${status}.`); } return true; } return await performStartupHandshake(createResponseWait, attachedExitPromise); } finally { resolvedDeps.removeDir(tempDir); } }