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

@@ -73,6 +73,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
@@ -220,6 +221,7 @@ export function createMainIpcRuntimeServiceDeps(
getKeybindings: params.getKeybindings,
getConfiguredShortcuts: params.getConfiguredShortcuts,
getStatsToggleKey: params.getStatsToggleKey,
getMarkWatchedKey: params.getMarkWatchedKey,
getControllerConfig: params.getControllerConfig,
saveControllerConfig: params.saveControllerConfig,
saveControllerPreference: params.saveControllerPreference,

View File

@@ -5,6 +5,7 @@ import {
requestSingleInstanceLockEarly,
resetEarlySingleInstanceStateForTests,
} from './early-single-instance';
import * as earlySingleInstance from './early-single-instance';
function createFakeApp(lockValue = true) {
let requestCalls = 0;
@@ -54,3 +55,16 @@ test('registerSecondInstanceHandlerEarly replays queued argv and forwards new ev
['SubMiner.exe', '--start', '--show-visible-overlay'],
]);
});
test('stats daemon args bypass the normal single-instance lock path', () => {
const shouldBypass = (
earlySingleInstance as typeof earlySingleInstance & {
shouldBypassSingleInstanceLockForArgv?: (argv: string[]) => boolean;
}
).shouldBypassSingleInstanceLockForArgv;
assert.equal(typeof shouldBypass, 'function');
assert.equal(shouldBypass?.(['SubMiner', '--stats', '--stats-background']), true);
assert.equal(shouldBypass?.(['SubMiner', '--stats', '--stats-stop']), true);
assert.equal(shouldBypass?.(['SubMiner', '--stats']), false);
});

View File

@@ -3,6 +3,10 @@ interface ElectronSecondInstanceAppLike {
on: (event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => unknown;
}
export function shouldBypassSingleInstanceLockForArgv(argv: readonly string[]): boolean {
return argv.includes('--stats-background') || argv.includes('--stats-stop');
}
let cachedSingleInstanceLock: boolean | null = null;
let secondInstanceListenerAttached = false;
const secondInstanceArgvHistory: string[][] = [];

View File

@@ -27,6 +27,11 @@ function makeHandler(
calls.push('ensureStatsServerStarted');
return 'http://127.0.0.1:6969';
},
ensureBackgroundStatsServerStarted: () => ({
url: 'http://127.0.0.1:6969',
runningInCurrentProcess: true,
}),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
openExternal: async (url) => {
calls.push(`openExternal:${url}`);
},
@@ -70,6 +75,88 @@ test('stats cli command starts tracker, server, browser, and writes success resp
]);
});
test('stats cli command starts background daemon without opening browser', async () => {
const { handler, calls, responses } = makeHandler({
ensureBackgroundStatsServerStarted: () => {
calls.push('ensureBackgroundStatsServerStarted');
return { url: 'http://127.0.0.1:6969', runningInCurrentProcess: true };
},
} as never);
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsBackground: true,
} as never,
'initial',
);
assert.deepEqual(calls, [
'ensureBackgroundStatsServerStarted',
'info:Stats dashboard available at http://127.0.0.1:6969',
]);
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true, url: 'http://127.0.0.1:6969' },
},
]);
});
test('stats cli command exits helper app when background daemon is already running elsewhere', async () => {
const { handler, calls, responses } = makeHandler({
ensureBackgroundStatsServerStarted: () => {
calls.push('ensureBackgroundStatsServerStarted');
return { url: 'http://127.0.0.1:6969', runningInCurrentProcess: false };
},
} as never);
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsBackground: true,
} as never,
'initial',
);
assert.ok(calls.includes('exitAppWithCode:0'));
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true, url: 'http://127.0.0.1:6969' },
},
]);
});
test('stats cli command stops background daemon and treats stale state as success', async () => {
const { handler, calls, responses } = makeHandler({
stopBackgroundStatsServer: async () => {
calls.push('stopBackgroundStatsServer');
return { ok: true, stale: true };
},
} as never);
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsStop: true,
} as never,
'initial',
);
assert.deepEqual(calls, [
'stopBackgroundStatsServer',
'info:Background stats server is not running; cleaned stale state.',
'exitAppWithCode:0',
]);
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true },
},
]);
});
test('stats cli command fails when immersion tracking is disabled', async () => {
const { handler, calls, responses } = makeHandler({
getResolvedConfig: () => ({

View File

@@ -22,6 +22,16 @@ export type StatsCliCommandResponse = {
error?: string;
};
type BackgroundStatsStartResult = {
url: string;
runningInCurrentProcess: boolean;
};
type BackgroundStatsStopResult = {
ok: boolean;
stale: boolean;
};
export function writeStatsCliCommandResponse(
responsePath: string,
payload: StatsCliCommandResponse,
@@ -39,6 +49,8 @@ export function createRunStatsCliCommandHandler(deps: {
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
} | null;
ensureStatsServerStarted: () => string;
ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult;
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
openExternal: (url: string) => Promise<unknown>;
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
exitAppWithCode: (code: number) => void;
@@ -61,16 +73,45 @@ export function createRunStatsCliCommandHandler(deps: {
return async (
args: Pick<
CliArgs,
'statsResponsePath' | 'statsCleanup' | 'statsCleanupVocab' | 'statsCleanupLifetime'
| 'statsResponsePath'
| 'statsBackground'
| 'statsStop'
| 'statsCleanup'
| 'statsCleanupVocab'
| 'statsCleanupLifetime'
>,
source: CliCommandSource,
): Promise<void> => {
try {
if (args.statsStop) {
const result = await deps.stopBackgroundStatsServer();
deps.logInfo(
result.stale
? 'Background stats server is not running; cleaned stale state.'
: 'Background stats server stopped.',
);
writeResponseSafe(args.statsResponsePath, { ok: true });
if (source === 'initial') {
deps.exitAppWithCode(0);
}
return;
}
const config = deps.getResolvedConfig();
if (config.immersionTracking?.enabled === false) {
throw new Error('Immersion tracking is disabled in config.');
}
if (args.statsBackground) {
const result = deps.ensureBackgroundStatsServerStarted();
deps.logInfo(`Stats dashboard available at ${result.url}`);
writeResponseSafe(args.statsResponsePath, { ok: true, url: result.url });
if (!result.runningInCurrentProcess && source === 'initial') {
deps.exitAppWithCode(0);
}
return;
}
deps.ensureImmersionTrackerStarted();
const tracker = deps.getImmersionTracker();
if (!tracker) {

View File

@@ -0,0 +1,72 @@
import fs from 'node:fs';
import path from 'node:path';
export type BackgroundStatsServerState = {
pid: number;
port: number;
startedAtMs: number;
};
export function readBackgroundStatsServerState(
statePath: string,
): BackgroundStatsServerState | null {
try {
const raw = JSON.parse(
fs.readFileSync(statePath, 'utf8'),
) as Partial<BackgroundStatsServerState>;
const pid = raw.pid;
const port = raw.port;
const startedAtMs = raw.startedAtMs;
if (
typeof pid !== 'number' ||
!Number.isInteger(pid) ||
pid <= 0 ||
typeof port !== 'number' ||
!Number.isInteger(port) ||
port <= 0 ||
typeof startedAtMs !== 'number' ||
!Number.isInteger(startedAtMs) ||
startedAtMs <= 0
) {
return null;
}
return {
pid,
port,
startedAtMs,
};
} catch {
return null;
}
}
export function writeBackgroundStatsServerState(
statePath: string,
state: BackgroundStatsServerState,
): void {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
}
export function removeBackgroundStatsServerState(statePath: string): void {
try {
fs.rmSync(statePath, { force: true });
} catch {
// ignore
}
}
export function isBackgroundStatsServerProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function resolveBackgroundStatsServerUrl(
state: Pick<BackgroundStatsServerState, 'port'>,
): string {
return `http://127.0.0.1:${state.port}`;
}