mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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[][] = [];
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
72
src/main/runtime/stats-daemon.ts
Normal file
72
src/main/runtime/stats-daemon.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user