From 53aa58d04413dd121f6438857f9970527dfef12c Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 26 Apr 2026 19:26:01 -0700 Subject: [PATCH] Route stats background mode through isolated daemon and defer in-app startup to live daemon (#58) --- ...-stats-daemon-from-regular-SubMiner-app.md | 54 ++++++++++++ ...rver-to-running-background-stats-daemon.md | 58 +++++++++++++ changes/306-stats-background-daemon.md | 4 + changes/307-stats-background-deferral.md | 4 + package.json | 2 +- src/main-entry-runtime.test.ts | 46 ++++++++++ src/main-entry-runtime.ts | 12 ++- src/main.ts | 42 +++++---- src/main/runtime/stats-server-routing.test.ts | 86 +++++++++++++++++++ src/main/runtime/stats-server-routing.ts | 41 +++++++++ src/stats-daemon-entry.ts | 4 +- 11 files changed, 333 insertions(+), 20 deletions(-) create mode 100644 backlog/tasks/task-306 - Separate-background-stats-daemon-from-regular-SubMiner-app.md create mode 100644 backlog/tasks/task-307 - Defer-in-app-stats-server-to-running-background-stats-daemon.md create mode 100644 changes/306-stats-background-daemon.md create mode 100644 changes/307-stats-background-deferral.md create mode 100644 src/main/runtime/stats-server-routing.test.ts create mode 100644 src/main/runtime/stats-server-routing.ts diff --git a/backlog/tasks/task-306 - Separate-background-stats-daemon-from-regular-SubMiner-app.md b/backlog/tasks/task-306 - Separate-background-stats-daemon-from-regular-SubMiner-app.md new file mode 100644 index 00000000..d1166d27 --- /dev/null +++ b/backlog/tasks/task-306 - Separate-background-stats-daemon-from-regular-SubMiner-app.md @@ -0,0 +1,54 @@ +--- +id: TASK-306 +title: Separate background stats daemon from regular SubMiner app +status: Done +assignee: [] +created_date: '2026-04-27 00:56' +updated_date: '2026-04-27 01:00' +labels: + - stats + - runtime +dependencies: [] +priority: high +--- + +## Description + + +Background stats mode should run only the stats data/server pieces. It must not bring up tray UI or expose the regular mpv connection surface, and stopping should remain CLI-only. + + +## Acceptance Criteria + +- [x] #1 Launching stats background mode starts a separate stats daemon process rather than booting the regular SubMiner runtime. +- [x] #2 Background stats mode does not create or keep a tray icon. +- [x] #3 Background stats mode does not start mpv IPC/client surfaces that let mpv connect to the app. +- [x] #4 Background stats mode remains stoppable through the stats stop command line path. + + +## Implementation Plan + + +1. Add entry-runtime tests for public stats background/stop daemon detection. +2. Implement early public stats daemon command detection and route it before regular app boot. +3. Run targeted tests and update task status/criteria. + + +## Implementation Notes + + +Implemented early public stats daemon routing in main-entry runtime. Direct `--stats-background` and `--stats-stop` now resolve to daemon control before single-instance lock and before loading `main.js`, matching the existing internal launcher daemon flags. Installed missing Bun dependencies to run targeted tests. + + +## Final Summary + + +Summary: +- Added `resolveStatsDaemonCommandAction` and updated entry detection so public `--stats-background` / `--stats-stop` invocations route through the isolated stats daemon control path. +- Reused that action resolution in `stats-daemon-entry` so public stop commands map to stop instead of the default start path. +- Added regression coverage for public daemon detection/action resolution. + +Verification: +- `bun test src/main-entry-runtime.test.ts launcher/commands/command-modules.test.ts src/main/runtime/stats-cli-command.test.ts src/stats-daemon-control.test.ts` +- `bun run typecheck` + diff --git a/backlog/tasks/task-307 - Defer-in-app-stats-server-to-running-background-stats-daemon.md b/backlog/tasks/task-307 - Defer-in-app-stats-server-to-running-background-stats-daemon.md new file mode 100644 index 00000000..d7479395 --- /dev/null +++ b/backlog/tasks/task-307 - Defer-in-app-stats-server-to-running-background-stats-daemon.md @@ -0,0 +1,58 @@ +--- +id: TASK-307 +title: Defer in-app stats server to running background stats daemon +status: Done +assignee: [] +created_date: '2026-04-27 01:57' +updated_date: '2026-04-27 02:02' +labels: + - stats + - runtime +dependencies: [] +priority: high +--- + +## Description + + +When normal SubMiner app startup has stats auto-start enabled, it should detect an already-running background stats daemon and avoid starting a second in-app stats server. Stats overlay/dashboard URL resolution should point at the background daemon. + + +## Acceptance Criteria + +- [x] #1 If a live background stats daemon state exists for another process, in-app stats auto-start does not start a local stats server. +- [x] #2 Stats URL resolution returns the background daemon URL when the background daemon is live. +- [x] #3 Stale or dead background daemon state is cleared and normal in-app stats startup still works. +- [x] #4 Regression tests cover the deferral behavior. + + +## Implementation Plan + + +1. Add unit tests for stats server routing decisions around live/stale background daemon state. +2. Implement a small routing helper used by main stats startup. +3. Wire `ensureStatsServerStarted()` through the helper. +4. Run targeted tests/typecheck/changelog lint and finalize the task. + + +## Implementation Notes + + +Extracted stats server URL routing into `src/main/runtime/stats-server-routing.ts` and wired `main.ts` through it. The helper returns the background daemon URL without calling local server startup when a live external daemon exists; dead/self-owned stale state is removed before falling back to local startup. Added the new test to `test:core:src`. + + +## Final Summary + + +Summary: +- Added a pure stats server routing helper that chooses between a live background daemon and local in-app stats server startup. +- Updated main stats URL resolution to defer to another process's background daemon and only start the in-app server when no live daemon is available. +- Added regression tests for live daemon deferral, dead daemon cleanup, self-owned stale state cleanup, and local server reuse. +- Added the routing test to the core source test lane and added a changelog fragment. + +Verification: +- `bun test src/main/runtime/stats-server-routing.test.ts src/main-entry-runtime.test.ts src/main/runtime/stats-cli-command.test.ts src/stats-daemon-control.test.ts` +- `bun run test:core:src` +- `bun run typecheck` +- `bun run changelog:lint` + diff --git a/changes/306-stats-background-daemon.md b/changes/306-stats-background-daemon.md new file mode 100644 index 00000000..408a108b --- /dev/null +++ b/changes/306-stats-background-daemon.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Stats background mode now routes through the isolated stats daemon instead of starting the regular SubMiner app runtime. diff --git a/changes/307-stats-background-deferral.md b/changes/307-stats-background-deferral.md new file mode 100644 index 00000000..8fa2bb34 --- /dev/null +++ b/changes/307-stats-background-deferral.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- In-app stats startup now defers to an already-running background stats daemon instead of starting a second stats server. diff --git a/package.json b/package.json index a189db1e..aafafe65 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts", + "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts", "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index 54cb79e7..b0f8bd54 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -5,6 +5,7 @@ import { normalizeLaunchMpvExtraArgs, normalizeStartupArgv, normalizeLaunchMpvTargets, + resolveStatsDaemonCommandAction, sanitizeHelpEnv, sanitizeLaunchMpvEnv, sanitizeStartupEnv, @@ -164,6 +165,51 @@ test('stats-daemon entry helper detects internal daemon commands', () => { assert.equal(shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--start'], {}), false); }); +test('stats-daemon entry helper detects public background stats commands', () => { + assert.equal( + shouldHandleStatsDaemonCommandAtEntry( + ['SubMiner.AppImage', '--stats', '--stats-background'], + {}, + ), + true, + ); + assert.equal( + shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats', '--stats-stop'], {}), + true, + ); + assert.equal( + shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-background'], {}), + true, + ); + assert.equal( + shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-background'], { + ELECTRON_RUN_AS_NODE: '1', + }), + false, + ); + assert.equal(shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats'], {}), false); +}); + +test('stats-daemon entry helper resolves daemon action for public and internal commands', () => { + assert.equal( + resolveStatsDaemonCommandAction(['SubMiner.AppImage', '--stats-daemon-start']), + 'start', + ); + assert.equal( + resolveStatsDaemonCommandAction(['SubMiner.AppImage', '--stats-daemon-stop']), + 'stop', + ); + assert.equal( + resolveStatsDaemonCommandAction(['SubMiner.AppImage', '--stats', '--stats-background']), + 'start', + ); + assert.equal( + resolveStatsDaemonCommandAction(['SubMiner.AppImage', '--stats', '--stats-stop']), + 'stop', + ); + assert.equal(resolveStatsDaemonCommandAction(['SubMiner.AppImage', '--stats']), null); +}); + test('sanitizeStartupEnv suppresses warnings and lsfg layer', () => { const env = sanitizeStartupEnv({ VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar', diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index 275d6003..e88b81a0 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -143,7 +143,17 @@ export function shouldHandleStatsDaemonCommandAtEntry( env: NodeJS.ProcessEnv, ): boolean { if (env.ELECTRON_RUN_AS_NODE === '1') return false; - return argv.includes('--stats-daemon-start') || argv.includes('--stats-daemon-stop'); + return resolveStatsDaemonCommandAction(argv) !== null; +} + +export function resolveStatsDaemonCommandAction(argv: string[]): 'start' | 'stop' | null { + if (argv.includes('--stats-daemon-stop') || argv.includes('--stats-stop')) { + return 'stop'; + } + if (argv.includes('--stats-daemon-start') || argv.includes('--stats-background')) { + return 'start'; + } + return null; } export function normalizeLaunchMpvTargets(argv: string[]): string[] { diff --git a/src/main.ts b/src/main.ts index 85dd19da..a29a45f5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -404,6 +404,7 @@ import { resolveBackgroundStatsServerUrl, writeBackgroundStatsServerState, } from './main/runtime/stats-daemon'; +import { createEnsureStatsServerUrlHandler } from './main/runtime/stats-server-routing'; import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos'; import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; import { @@ -3170,11 +3171,7 @@ registerProtocolUrlHandlersHandler(); 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 startLocalStatsServer = (): void => { const tracker = appState.immersionTracker; if (!tracker) { throw new Error('Immersion tracker failed to initialize.'); @@ -3224,9 +3221,20 @@ const ensureStatsServerStarted = (): string => { appState.statsServer = statsServer; } appState.statsServer = statsServer; - return `http://127.0.0.1:${getResolvedConfig().stats.serverPort}`; }; +const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({ + currentPid: process.pid, + readBackgroundState: () => readBackgroundStatsServerState(statsDaemonStatePath), + removeBackgroundState: () => { + removeBackgroundStatsServerState(statsDaemonStatePath); + }, + isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(pid), + hasLocalStatsServer: () => statsServer !== null, + startLocalStatsServer, + getConfiguredPort: () => getResolvedConfig().stats.serverPort, +}); + const ensureBackgroundStatsServerStarted = (): { url: string; runningInCurrentProcess: boolean; @@ -3247,13 +3255,15 @@ const ensureBackgroundStatsServerStarted = (): { } const port = getResolvedConfig().stats.serverPort; - const url = ensureStatsServerStarted(); - writeBackgroundStatsServerState(statsDaemonStatePath, { - pid: process.pid, - port, - startedAtMs: Date.now(), - }); - return { url, runningInCurrentProcess: true }; + const result = ensureStatsServerStarted(); + if (result.source === 'local') { + writeBackgroundStatsServerState(statsDaemonStatePath, { + pid: process.pid, + port, + startedAtMs: Date.now(), + }); + } + return { url: result.url, runningInCurrentProcess: result.source === 'local' }; }; const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => { @@ -3352,7 +3362,7 @@ const immersionTrackerStartupMainDeps: Parameters< registerStatsOverlayToggle({ staticDir: statsDistPath, preloadPath: statsPreloadPath, - getApiBaseUrl: () => ensureStatsServerStarted(), + getApiBaseUrl: () => ensureStatsServerStarted().url, getToggleKey: () => getResolvedConfig().stats.toggleKey, resolveBounds: () => getCurrentOverlayGeometry(), onVisibilityChanged: (visible) => { @@ -3407,7 +3417,7 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({ await createMecabTokenizerAndCheck(); }, getImmersionTracker: () => appState.immersionTracker, - ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted(), + ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted().url, ensureBackgroundStatsServerStarted: () => statsStartupRuntime.ensureBackgroundStatsServerStarted(), stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(), @@ -4619,7 +4629,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro toggleStatsOverlayWindow({ staticDir: statsDistPath, preloadPath: statsPreloadPath, - getApiBaseUrl: () => ensureStatsServerStarted(), + getApiBaseUrl: () => ensureStatsServerStarted().url, getToggleKey: () => getResolvedConfig().stats.toggleKey, resolveBounds: () => getCurrentOverlayGeometry(), onVisibilityChanged: (visible) => { diff --git a/src/main/runtime/stats-server-routing.test.ts b/src/main/runtime/stats-server-routing.test.ts new file mode 100644 index 00000000..df799895 --- /dev/null +++ b/src/main/runtime/stats-server-routing.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createEnsureStatsServerUrlHandler } from './stats-server-routing'; + +function createHarness(options?: { + state?: { pid: number; port: number; startedAtMs: number } | null; + processAlive?: boolean; + localServerStarted?: boolean; +}) { + const calls: string[] = []; + let localServerStarted = options?.localServerStarted ?? false; + const handler = createEnsureStatsServerUrlHandler({ + currentPid: 100, + readBackgroundState: () => { + calls.push('readBackgroundState'); + return options?.state ?? null; + }, + removeBackgroundState: () => { + calls.push('removeBackgroundState'); + }, + isProcessAlive: () => { + calls.push('isProcessAlive'); + return options?.processAlive ?? true; + }, + hasLocalStatsServer: () => localServerStarted, + startLocalStatsServer: () => { + calls.push('startLocalStatsServer'); + localServerStarted = true; + }, + getConfiguredPort: () => 6969, + }); + + return { + calls, + handler, + }; +} + +test('stats server routing defers to a live background daemon from another process', () => { + const { calls, handler } = createHarness({ + state: { pid: 200, port: 7979, startedAtMs: 1 }, + processAlive: true, + }); + + assert.deepEqual(handler(), { url: 'http://127.0.0.1:7979', source: 'foreign' }); + assert.deepEqual(calls, ['readBackgroundState', 'isProcessAlive']); +}); + +test('stats server routing clears dead daemon state and starts local server', () => { + const { calls, handler } = createHarness({ + state: { pid: 200, port: 7979, startedAtMs: 1 }, + processAlive: false, + }); + + assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' }); + assert.deepEqual(calls, [ + 'readBackgroundState', + 'isProcessAlive', + 'removeBackgroundState', + 'startLocalStatsServer', + ]); +}); + +test('stats server routing clears self-owned stale state and starts local server', () => { + const { calls, handler } = createHarness({ + state: { pid: 100, port: 7979, startedAtMs: 1 }, + processAlive: true, + }); + + assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' }); + assert.deepEqual(calls, [ + 'readBackgroundState', + 'removeBackgroundState', + 'startLocalStatsServer', + ]); +}); + +test('stats server routing reuses a started local stats server', () => { + const { calls, handler } = createHarness({ + state: null, + localServerStarted: true, + }); + + assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' }); + assert.deepEqual(calls, ['readBackgroundState', 'removeBackgroundState']); +}); diff --git a/src/main/runtime/stats-server-routing.ts b/src/main/runtime/stats-server-routing.ts new file mode 100644 index 00000000..ab38949e --- /dev/null +++ b/src/main/runtime/stats-server-routing.ts @@ -0,0 +1,41 @@ +import type { BackgroundStatsServerState } from './stats-daemon'; + +type EnsureStatsServerUrlDeps = { + currentPid: number; + readBackgroundState: () => BackgroundStatsServerState | null; + removeBackgroundState: () => void; + isProcessAlive: (pid: number) => boolean; + hasLocalStatsServer: () => boolean; + startLocalStatsServer: () => void; + getConfiguredPort: () => number; +}; + +function formatStatsServerUrl(port: number): string { + return `http://127.0.0.1:${port}`; +} + +export type EnsureStatsServerUrlResult = + | { url: string; source: 'foreign' } + | { url: string; source: 'local' }; + +export function createEnsureStatsServerUrlHandler( + deps: EnsureStatsServerUrlDeps, +): () => EnsureStatsServerUrlResult { + return () => { + const state = deps.readBackgroundState(); + if (!state) { + deps.removeBackgroundState(); + } else if (state.pid === deps.currentPid && !deps.hasLocalStatsServer()) { + deps.removeBackgroundState(); + } else if (!deps.isProcessAlive(state.pid)) { + deps.removeBackgroundState(); + } else if (state.pid !== deps.currentPid) { + return { url: formatStatsServerUrl(state.port), source: 'foreign' }; + } + + if (!deps.hasLocalStatsServer()) { + deps.startLocalStatsServer(); + } + return { url: formatStatsServerUrl(deps.getConfiguredPort()), source: 'local' }; + }; +} diff --git a/src/stats-daemon-entry.ts b/src/stats-daemon-entry.ts index 0099f9ec..78dccc38 100644 --- a/src/stats-daemon-entry.ts +++ b/src/stats-daemon-entry.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { shell } from 'electron'; -import { sanitizeStartupEnv } from './main-entry-runtime'; +import { resolveStatsDaemonCommandAction, sanitizeStartupEnv } from './main-entry-runtime'; import { isBackgroundStatsServerProcessAlive, readBackgroundStatsServerState, @@ -44,7 +44,7 @@ function hasFlag(argv: string[], flag: string): boolean { function parseControlArgs(argv: string[], userDataPath: string): StatsDaemonControlArgs { return { - action: hasFlag(argv, '--stats-daemon-stop') ? 'stop' : 'start', + action: resolveStatsDaemonCommandAction(argv) ?? 'start', responsePath: readFlagValue(argv, '--stats-response-path'), openBrowser: hasFlag(argv, '--stats-daemon-open-browser'), daemonScriptPath: path.join(__dirname, 'stats-daemon-runner.js'),