mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -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:
@@ -159,6 +159,36 @@ test('stats command launches attached app command with response path', async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats background command launches detached app command with response path', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
(context.args as typeof context.args & { statsBackground?: boolean }).statsBackground = true;
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
const handled = await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async () => {
|
||||
throw new Error('attached path should not run for stats -b');
|
||||
},
|
||||
launchAppCommandDetached: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
||||
removeDir: () => {},
|
||||
} as Parameters<typeof runStatsCommand>[1]);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--stats-background',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats command returns after startup response even if app process stays running', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
@@ -185,11 +215,7 @@ test('stats command returns after startup response even if app process stays run
|
||||
const final = await statsCommand;
|
||||
assert.equal(final, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
],
|
||||
['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -223,6 +249,50 @@ test('stats cleanup command forwards cleanup vocab flags to the app', async () =
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats stop command forwards stop flag to the app', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
(context.args as typeof context.args & { statsStop?: boolean }).statsStop = true;
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
const handled = await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
return 0;
|
||||
},
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
removeDir: () => {},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json', '--stats-stop'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats stop command exits on process exit without waiting for startup response', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
(context.args as typeof context.args & { statsStop?: boolean }).statsStop = true;
|
||||
let waitedForResponse = false;
|
||||
|
||||
const handled = await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async () => 0,
|
||||
waitForStatsResponse: async () => {
|
||||
waitedForResponse = true;
|
||||
return { ok: true };
|
||||
},
|
||||
removeDir: () => {},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(waitedForResponse, false);
|
||||
});
|
||||
|
||||
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
@@ -193,6 +195,8 @@ export function applyRootOptionsToArgs(
|
||||
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
||||
if (invocations.dictionaryTriggered) parsed.dictionary = true;
|
||||
if (invocations.statsTriggered) parsed.stats = true;
|
||||
if (invocations.statsBackground) parsed.statsBackground = true;
|
||||
if (invocations.statsStop) parsed.statsStop = true;
|
||||
if (invocations.statsCleanup) parsed.statsCleanup = true;
|
||||
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
|
||||
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true;
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface CliInvocations {
|
||||
dictionaryTarget: string | null;
|
||||
dictionaryLogLevel: string | null;
|
||||
statsTriggered: boolean;
|
||||
statsBackground: boolean;
|
||||
statsStop: boolean;
|
||||
statsCleanup: boolean;
|
||||
statsCleanupVocab: boolean;
|
||||
statsCleanupLifetime: boolean;
|
||||
@@ -144,6 +146,8 @@ export function parseCliPrograms(
|
||||
let dictionaryTarget: string | null = null;
|
||||
let dictionaryLogLevel: string | null = null;
|
||||
let statsTriggered = false;
|
||||
let statsBackground = false;
|
||||
let statsStop = false;
|
||||
let statsCleanup = false;
|
||||
let statsCleanupVocab = false;
|
||||
let statsCleanupLifetime = false;
|
||||
@@ -256,12 +260,22 @@ export function parseCliPrograms(
|
||||
.command('stats')
|
||||
.description('Launch the local immersion stats dashboard')
|
||||
.argument('[action]', 'cleanup|rebuild|backfill')
|
||||
.option('-b, --background', 'Start the stats server in the background')
|
||||
.option('-s, --stop', 'Stop the background stats server')
|
||||
.option('-v, --vocab', 'Clean vocabulary rows in the stats database')
|
||||
.option('-l, --lifetime', 'Rebuild lifetime summary rows from retained data')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
statsTriggered = true;
|
||||
const normalizedAction = (action || '').toLowerCase();
|
||||
statsBackground = options.background === true;
|
||||
statsStop = options.stop === true;
|
||||
if (statsBackground && statsStop) {
|
||||
throw new Error('Stats background and stop flags cannot be combined.');
|
||||
}
|
||||
if (normalizedAction && (statsBackground || statsStop)) {
|
||||
throw new Error('Stats background and stop flags cannot be combined with stats actions.');
|
||||
}
|
||||
if (normalizedAction === 'cleanup') {
|
||||
statsCleanup = true;
|
||||
statsCleanupLifetime = options.lifetime === true;
|
||||
@@ -353,6 +367,8 @@ export function parseCliPrograms(
|
||||
dictionaryTarget,
|
||||
dictionaryLogLevel,
|
||||
statsTriggered,
|
||||
statsBackground,
|
||||
statsStop,
|
||||
statsCleanup,
|
||||
statsCleanupVocab,
|
||||
statsCleanupLifetime,
|
||||
|
||||
@@ -66,6 +66,28 @@ test('parseArgs maps stats command and log-level override', () => {
|
||||
assert.equal(parsed.logLevel, 'debug');
|
||||
});
|
||||
|
||||
test('parseArgs maps stats background flag', () => {
|
||||
const parsed = parseArgs(['stats', '-b'], 'subminer', {}) as ReturnType<typeof parseArgs> & {
|
||||
statsBackground?: boolean;
|
||||
statsStop?: boolean;
|
||||
};
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsBackground, true);
|
||||
assert.equal(parsed.statsStop, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps stats stop flag', () => {
|
||||
const parsed = parseArgs(['stats', '-s'], 'subminer', {}) as ReturnType<typeof parseArgs> & {
|
||||
statsBackground?: boolean;
|
||||
statsStop?: boolean;
|
||||
};
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsStop, true);
|
||||
assert.equal(parsed.statsBackground, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps stats cleanup to vocab mode by default', () => {
|
||||
const parsed = parseArgs(['stats', 'cleanup'], 'subminer', {});
|
||||
|
||||
|
||||
@@ -112,6 +112,8 @@ export interface Args {
|
||||
jellyfinDiscovery: boolean;
|
||||
dictionary: boolean;
|
||||
stats: boolean;
|
||||
statsBackground?: boolean;
|
||||
statsStop?: boolean;
|
||||
statsCleanup?: boolean;
|
||||
statsCleanupVocab?: boolean;
|
||||
statsCleanupLifetime?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user