feat: add background stats server daemon lifecycle

Implement `subminer stats -b` to start a background stats daemon and
`subminer stats -s` to stop it, with PID-based process lifecycle
management, single-instance lock bypass for daemon mode, and automatic
reuse of running daemon instances.
This commit is contained in:
2026-03-17 19:54:04 -07:00
parent 55ee12e87f
commit 08a5401a7d
20 changed files with 776 additions and 33 deletions

View File

@@ -1,7 +1,7 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runAppCommandAttached } from '../mpv.js';
import { launchAppCommandDetached, runAppCommandAttached } from '../mpv.js';
import { sleep } from '../util.js';
import type { LauncherCommandContext } from './context.js';
@@ -20,17 +20,25 @@ type StatsCommandDeps = {
logLevel: LauncherCommandContext['args']['logLevel'],
label: string,
) => Promise<number>;
launchAppCommandDetached: (
appPath: string,
appArgs: string[],
logLevel: LauncherCommandContext['args']['logLevel'],
label: string,
) => void;
waitForStatsResponse: (responsePath: string) => Promise<StatsCommandResponse>;
removeDir: (targetPath: string) => void;
};
const STATS_STARTUP_RESPONSE_TIMEOUT_MS = 8_000;
const STATS_STARTUP_RESPONSE_TIMEOUT_MS = 12_000;
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),
launchAppCommandDetached: (appPath, appArgs, logLevel, label) =>
launchAppCommandDetached(appPath, appArgs, logLevel, label),
waitForStatsResponse: async (responsePath) => {
const deadline = Date.now() + STATS_STARTUP_RESPONSE_TIMEOUT_MS;
while (Date.now() < deadline) {
@@ -55,18 +63,25 @@ const defaultDeps: StatsCommandDeps = {
export async function runStatsCommand(
context: LauncherCommandContext,
deps: StatsCommandDeps = defaultDeps,
deps: Partial<StatsCommandDeps> = {},
): Promise<boolean> {
const resolvedDeps: StatsCommandDeps = { ...defaultDeps, ...deps };
const { args, appPath } = context;
if (!args.stats || !appPath) {
return false;
}
const tempDir = deps.createTempDir('subminer-stats-');
const responsePath = deps.joinPath(tempDir, 'response.json');
const tempDir = resolvedDeps.createTempDir('subminer-stats-');
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
try {
const forwarded = ['--stats', '--stats-response-path', responsePath];
if (args.statsBackground) {
forwarded.push('--stats-background');
}
if (args.statsStop) {
forwarded.push('--stats-stop');
}
if (args.statsCleanup) {
forwarded.push('--stats-cleanup');
}
@@ -79,11 +94,32 @@ export async function runStatsCommand(
if (args.logLevel !== 'info') {
forwarded.push('--log-level', args.logLevel);
}
const attachedExitPromise = deps.runAppCommandAttached(appPath, forwarded, args.logLevel, 'stats');
if (args.statsBackground) {
resolvedDeps.launchAppCommandDetached(appPath, forwarded, args.logLevel, 'stats');
const startupResult = await resolvedDeps.waitForStatsResponse(responsePath);
if (!startupResult.ok) {
throw new Error(startupResult.error || 'Stats dashboard failed to start.');
}
return true;
}
const attachedExitPromise = resolvedDeps.runAppCommandAttached(
appPath,
forwarded,
args.logLevel,
'stats',
);
if (!args.statsCleanup) {
if (args.statsStop) {
const status = await attachedExitPromise;
if (status !== 0) {
throw new Error(`Stats app exited with status ${status}.`);
}
return true;
}
if (!args.statsCleanup && !args.statsStop) {
const startupResult = await Promise.race([
deps
resolvedDeps
.waitForStatsResponse(responsePath)
.then((response) => ({ kind: 'response' as const, response })),
attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })),
@@ -94,7 +130,7 @@ export async function runStatsCommand(
`Stats app exited before startup response (status ${startupResult.status}).`,
);
}
const response = await deps.waitForStatsResponse(responsePath);
const response = await resolvedDeps.waitForStatsResponse(responsePath);
if (!response.ok) {
throw new Error(response.error || 'Stats dashboard failed to start.');
}
@@ -109,7 +145,7 @@ export async function runStatsCommand(
const attachedExitPromiseCleanup = attachedExitPromise;
const startupResult = await Promise.race([
deps
resolvedDeps
.waitForStatsResponse(responsePath)
.then((response) => ({ kind: 'response' as const, response })),
attachedExitPromiseCleanup.then((status) => ({ kind: 'exit' as const, status })),
@@ -120,7 +156,7 @@ export async function runStatsCommand(
`Stats app exited before startup response (status ${startupResult.status}).`,
);
}
const response = await deps.waitForStatsResponse(responsePath);
const response = await resolvedDeps.waitForStatsResponse(responsePath);
if (!response.ok) {
throw new Error(response.error || 'Stats dashboard failed to start.');
}
@@ -135,6 +171,6 @@ export async function runStatsCommand(
}
return true;
} finally {
deps.removeDir(tempDir);
resolvedDeps.removeDir(tempDir);
}
}