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

@@ -157,6 +157,26 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(stats), true);
assert.equal(shouldStartApp(stats), true);
const statsBackground = parseArgs(['--stats', '--stats-background']) as typeof stats & {
statsBackground?: boolean;
statsStop?: boolean;
};
assert.equal(statsBackground.stats, true);
assert.equal(statsBackground.statsBackground, true);
assert.equal(statsBackground.statsStop, false);
assert.equal(hasExplicitCommand(statsBackground), true);
assert.equal(shouldStartApp(statsBackground), true);
const statsStop = parseArgs(['--stats', '--stats-stop']) as typeof stats & {
statsBackground?: boolean;
statsStop?: boolean;
};
assert.equal(statsStop.stats, true);
assert.equal(statsStop.statsStop, true);
assert.equal(statsStop.statsBackground, false);
assert.equal(hasExplicitCommand(statsStop), true);
assert.equal(shouldStartApp(statsStop), true);
const statsLifetimeRebuild = parseArgs([
'--stats',
'--stats-cleanup',

View File

@@ -30,6 +30,8 @@ export interface CliArgs {
dictionary: boolean;
dictionaryTarget?: string;
stats: boolean;
statsBackground?: boolean;
statsStop?: boolean;
statsCleanup?: boolean;
statsCleanupVocab?: boolean;
statsCleanupLifetime?: boolean;
@@ -103,6 +105,8 @@ export function parseArgs(argv: string[]): CliArgs {
anilistRetryQueue: false,
dictionary: false,
stats: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
@@ -172,7 +176,13 @@ export function parseArgs(argv: string[]): CliArgs {
const value = readValue(argv[i + 1]);
if (value) args.dictionaryTarget = value;
} else if (arg === '--stats') args.stats = true;
else if (arg === '--stats-cleanup') args.statsCleanup = true;
else if (arg === '--stats-background') {
args.stats = true;
args.statsBackground = true;
} else if (arg === '--stats-stop') {
args.stats = true;
args.statsStop = true;
} else if (arg === '--stats-cleanup') args.statsCleanup = true;
else if (arg === '--stats-cleanup-vocab') args.statsCleanupVocab = true;
else if (arg === '--stats-cleanup-lifetime') args.statsCleanupLifetime = true;
else if (arg.startsWith('--stats-response-path=')) {

View File

@@ -3,6 +3,7 @@ import { ResolvedConfig } from '../../types.js';
export const STATS_DEFAULT_CONFIG: Pick<ResolvedConfig, 'stats'> = {
stats: {
toggleKey: 'Backquote',
markWatchedKey: 'KeyW',
serverPort: 6969,
autoStartServer: true,
autoOpenBrowser: true,

View File

@@ -11,6 +11,12 @@ export function buildStatsConfigOptionRegistry(
defaultValue: defaultConfig.stats.toggleKey,
description: 'Key code to toggle the stats overlay.',
},
{
path: 'stats.markWatchedKey',
kind: 'string',
defaultValue: defaultConfig.stats.markWatchedKey,
description: 'Key code to mark the current video as watched and advance to the next playlist entry.',
},
{
path: 'stats.serverPort',
kind: 'number',

View File

@@ -13,6 +13,13 @@ export function applyStatsConfig(context: ResolveContext): void {
warn('stats.toggleKey', src.stats.toggleKey, resolved.stats.toggleKey, 'Expected string.');
}
const markWatchedKey = asString(src.stats.markWatchedKey);
if (markWatchedKey !== undefined) {
resolved.stats.markWatchedKey = markWatchedKey;
} else if (src.stats.markWatchedKey !== undefined) {
warn('stats.markWatchedKey', src.stats.markWatchedKey, resolved.stats.markWatchedKey, 'Expected string.');
}
const serverPort = asNumber(src.stats.serverPort);
if (serverPort !== undefined) {
resolved.stats.serverPort = serverPort;

View File

@@ -23,6 +23,7 @@ const SESSION_SUMMARIES = [
cardsMined: 2,
lookupCount: 5,
lookupHits: 4,
yomitanLookupCount: 5,
},
];
@@ -147,6 +148,45 @@ const WATCH_TIME_PER_ANIME = [
},
];
const TRENDS_DASHBOARD = {
activity: {
watchTime: [{ label: 'Mar 1', value: 25 }],
cards: [{ label: 'Mar 1', value: 5 }],
words: [{ label: 'Mar 1', value: 300 }],
sessions: [{ label: 'Mar 1', value: 3 }],
},
progress: {
watchTime: [{ label: 'Mar 1', value: 25 }],
sessions: [{ label: 'Mar 1', value: 3 }],
words: [{ label: 'Mar 1', value: 300 }],
newWords: [{ label: 'Mar 1', value: 12 }],
cards: [{ label: 'Mar 1', value: 5 }],
episodes: [{ label: 'Mar 1', value: 2 }],
lookups: [{ label: 'Mar 1', value: 15 }],
},
ratios: {
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
},
animePerDay: {
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }],
lookups: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 15 }],
lookupsPerHundred: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
},
animeCumulative: {
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }],
},
patterns: {
watchTimeByDayOfWeek: [{ label: 'Sun', value: 25 }],
watchTimeByHour: [{ label: '12:00', value: 25 }],
},
};
const ANIME_EPISODES = [
{
animeId: 1,
@@ -238,6 +278,7 @@ function createMockTracker(
getEpisodesPerDay: async () => EPISODES_PER_DAY,
getNewAnimePerDay: async () => NEW_ANIME_PER_DAY,
getWatchTimePerAnime: async () => WATCH_TIME_PER_ANIME,
getTrendsDashboard: async () => TRENDS_DASHBOARD,
getStreakCalendar: async () => [
{ epochDay: Math.floor(Date.now() / 86_400_000) - 1, totalActiveMin: 30 },
{ epochDay: Math.floor(Date.now() / 86_400_000), totalActiveMin: 45 },
@@ -308,6 +349,37 @@ describe('stats server API routes', () => {
assert.ok(Array.isArray(body));
});
it('GET /api/stats/sessions/:id/known-words-timeline preserves line positions and counts known occurrences', async () => {
await withTempDir(async (dir) => {
const cachePath = path.join(dir, 'known-words.json');
fs.writeFileSync(
cachePath,
JSON.stringify({
version: 1,
words: ['知る', '猫'],
}),
);
const app = createStatsApp(
createMockTracker({
getSessionWordsByLine: async () => [
{ lineIndex: 1, headword: '知る', occurrenceCount: 2 },
{ lineIndex: 3, headword: '猫', occurrenceCount: 1 },
{ lineIndex: 3, headword: '見る', occurrenceCount: 4 },
],
}),
{ knownWordCachePath: cachePath },
);
const res = await app.request('/api/stats/sessions/1/known-words-timeline');
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), [
{ linesSeen: 1, knownWordsSeen: 2 },
{ linesSeen: 3, knownWordsSeen: 3 },
]);
});
});
it('GET /api/stats/vocabulary returns word frequency data', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/vocabulary');
@@ -429,6 +501,41 @@ describe('stats server API routes', () => {
assert.equal(seenLimit, 365);
});
it('GET /api/stats/trends/dashboard returns chart-ready trends data', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
createMockTracker({
getTrendsDashboard: async (...args: unknown[]) => {
seenArgs = args;
return TRENDS_DASHBOARD;
},
}),
);
const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(seenArgs, ['90d', 'month']);
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime);
});
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
createMockTracker({
getTrendsDashboard: async (...args: unknown[]) => {
seenArgs = args;
return TRENDS_DASHBOARD;
},
}),
);
const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['30d', 'day']);
});
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(

View File

@@ -17,6 +17,41 @@ function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: num
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
}
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | 'all' {
return raw === '7d' || raw === '30d' || raw === '90d' || raw === 'all' ? raw : '30d';
}
function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
return raw === 'month' ? 'month' : 'day';
}
/** Load known words cache from disk into a Set. Returns null if unavailable. */
function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
if (!cachePath || !existsSync(cachePath)) return null;
try {
const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as {
version?: number;
words?: string[];
};
if (raw.version === 1 && Array.isArray(raw.words)) return new Set(raw.words);
} catch {
/* ignore */
}
return null;
}
/** Count how many headwords in the given list are in the known words set. */
function countKnownWords(
headwords: string[],
knownWordsSet: Set<string>,
): { totalUniqueWords: number; knownWordCount: number } {
let knownWordCount = 0;
for (const hw of headwords) {
if (knownWordsSet.has(hw)) knownWordCount++;
}
return { totalUniqueWords: headwords.length, knownWordCount };
}
export interface StatsServerConfig {
port: number;
staticDir: string; // Path to stats/dist/
@@ -139,6 +174,12 @@ export function createStatsApp(
return c.json(await tracker.getWatchTimePerAnime(limit));
});
app.get('/api/stats/trends/dashboard', async (c) => {
const range = parseTrendRange(c.req.query('range'));
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
return c.json(await tracker.getTrendsDashboard(range, groupBy));
});
app.get('/api/stats/sessions', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
const sessions = await tracker.getSessionSummaries(limit);
@@ -161,6 +202,42 @@ export function createStatsApp(
return c.json(events);
});
app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json([], 400);
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json([]);
// Get per-line word occurrences for the session.
const wordsByLine = await tracker.getSessionWordsByLine(id);
// Build cumulative known-word occurrence count per recorded line index.
// The stats UI uses line-count progress to align this series with the session
// timeline, so preserve the stored line position rather than compressing gaps.
const lineGroups = new Map<number, number>();
for (const row of wordsByLine) {
if (!knownWordsSet.has(row.headword)) {
continue;
}
lineGroups.set(row.lineIndex, (lineGroups.get(row.lineIndex) ?? 0) + row.occurrenceCount);
}
const sortedLineIndices = [...lineGroups.keys()].sort((a, b) => a - b);
let knownWordsSeen = 0;
const knownByLinesSeen: Array<{ linesSeen: number; knownWordsSeen: number }> = [];
for (const lineIdx of sortedLineIndices) {
knownWordsSeen += lineGroups.get(lineIdx)!;
knownByLinesSeen.push({
linesSeen: lineIdx,
knownWordsSeen,
});
}
return c.json(knownByLinesSeen);
});
app.get('/api/stats/vocabulary', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean);
@@ -274,6 +351,16 @@ export function createStatsApp(
return c.json({ ok: true });
});
app.delete('/api/stats/sessions', async (c) => {
const body = await c.req.json().catch(() => null);
const ids = Array.isArray(body?.sessionIds)
? body.sessionIds.filter((id: unknown) => typeof id === 'number' && id > 0)
: [];
if (ids.length === 0) return c.body(null, 400);
await tracker.deleteSessions(ids);
return c.json({ ok: true });
});
app.delete('/api/stats/sessions/:sessionId', async (c) => {
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
if (sessionId <= 0) return c.body(null, 400);
@@ -320,18 +407,34 @@ export function createStatsApp(
});
app.get('/api/stats/known-words', (c) => {
const cachePath = options?.knownWordCachePath;
if (!cachePath || !existsSync(cachePath)) return c.json([]);
try {
const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as {
version?: number;
words?: string[];
};
if (raw.version === 1 && Array.isArray(raw.words)) return c.json(raw.words);
} catch {
/* ignore */
}
return c.json([]);
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json([]);
return c.json([...knownWordsSet]);
});
app.get('/api/stats/known-words-summary', async (c) => {
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
const headwords = await tracker.getAllDistinctHeadwords();
return c.json(countKnownWords(headwords, knownWordsSet));
});
app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }, 400);
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
const headwords = await tracker.getAnimeDistinctHeadwords(animeId);
return c.json(countKnownWords(headwords, knownWordsSet));
});
app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }, 400);
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
const headwords = await tracker.getMediaDistinctHeadwords(videoId);
return c.json(countKnownWords(headwords, knownWordsSet));
});
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {

View File

@@ -334,6 +334,13 @@ import {
createRunStatsCliCommandHandler,
writeStatsCliCommandResponse,
} from './main/runtime/stats-cli-command';
import {
isBackgroundStatsServerProcessAlive,
readBackgroundStatsServerState,
removeBackgroundStatsServerState,
resolveBackgroundStatsServerUrl,
writeBackgroundStatsServerState,
} from './main/runtime/stats-daemon';
import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos';
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
import {
@@ -366,6 +373,7 @@ import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
import {
registerSecondInstanceHandlerEarly,
requestSingleInstanceLockEarly,
shouldBypassSingleInstanceLockForArgv,
} from './main/early-single-instance';
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime';
@@ -600,7 +608,10 @@ const appLogger = {
};
const runtimeRegistry = createMainRuntimeRegistry();
const appLifecycleApp = {
requestSingleInstanceLock: () => requestSingleInstanceLockEarly(app),
requestSingleInstanceLock: () =>
shouldBypassSingleInstanceLockForArgv(process.argv)
? true
: requestSingleInstanceLockEarly(app),
quit: () => app.quit(),
on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') {
@@ -633,6 +644,35 @@ app.setPath('userData', USER_DATA_PATH);
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
let statsServer: ReturnType<typeof startStatsServer> | null = null;
const statsDaemonStatePath = path.join(USER_DATA_PATH, '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) {
@@ -640,6 +680,7 @@ function stopStatsServer(): void {
}
statsServer.close();
statsServer = null;
clearOwnedBackgroundStatsDaemonState();
}
function requestAppQuit(): void {
@@ -2524,6 +2565,10 @@ const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
const ensureStatsServerStarted = (): string => {
const liveDaemon = readLiveBackgroundStatsDaemonState();
if (liveDaemon && liveDaemon.pid !== process.pid) {
return resolveBackgroundStatsServerUrl(liveDaemon);
}
const tracker = appState.immersionTracker;
if (!tracker) {
throw new Error('Immersion tracker failed to initialize.');
@@ -2567,6 +2612,73 @@ const ensureStatsServerStarted = (): string => {
return `http://127.0.0.1:${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 url = ensureStatsServerStarted();
writeBackgroundStatsServerState(statsDaemonStatePath, {
pid: process.pid,
port,
startedAtMs: Date.now(),
});
return { url, runningInCurrentProcess: true };
};
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: {
headword: string;
word: string;
@@ -2675,6 +2787,8 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
},
getImmersionTracker: () => appState.immersionTracker,
ensureStatsServerStarted: () => ensureStatsServerStarted(),
ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(),
stopBackgroundStatsServer: () => stopBackgroundStatsServer(),
openExternal: (url: string) => shell.openExternal(url),
writeResponse: (responsePath, payload) => {
writeStatsCliCommandResponse(responsePath, payload);
@@ -2837,7 +2951,12 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
shouldUseMinimalStartup: () =>
Boolean(appState.initialArgs?.stats && appState.initialArgs?.statsCleanup),
Boolean(
appState.initialArgs?.stats &&
(appState.initialArgs?.statsCleanup ||
appState.initialArgs?.statsBackground ||
appState.initialArgs?.statsStop),
),
shouldSkipHeavyStartup: () =>
Boolean(
appState.initialArgs &&

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}`;
}