mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 05:16:20 -07:00
fix(stats): harden server lifecycle and verify compiled runtime (#261)
This commit is contained in:
@@ -93,12 +93,20 @@ jobs:
|
|||||||
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
|
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
|
||||||
lua -v
|
lua -v
|
||||||
|
|
||||||
- name: Test suite (source)
|
- name: Launcher unit and script suites
|
||||||
run: bun run test:fast
|
run: bun run test:launcher:unit:src && bun run test:scripts
|
||||||
|
|
||||||
- name: Environment suite
|
- name: Environment suite
|
||||||
run: bun run test:env
|
run: bun run test:env
|
||||||
|
|
||||||
|
- name: Upload launcher smoke artifacts (on failure)
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: launcher-smoke
|
||||||
|
path: .tmp/launcher-smoke/**
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
- name: Coverage suite (maintained source lane)
|
- name: Coverage suite (maintained source lane)
|
||||||
run: bun run test:coverage:src
|
run: bun run test:coverage:src
|
||||||
|
|
||||||
@@ -112,17 +120,6 @@ jobs:
|
|||||||
- name: Stats UI tests
|
- name: Stats UI tests
|
||||||
run: bun run test:stats
|
run: bun run test:stats
|
||||||
|
|
||||||
- name: Launcher smoke suite (source)
|
|
||||||
run: bun run test:launcher:smoke:src
|
|
||||||
|
|
||||||
- name: Upload launcher smoke artifacts (on failure)
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: launcher-smoke
|
|
||||||
path: .tmp/launcher-smoke/**
|
|
||||||
if-no-files-found: ignore
|
|
||||||
|
|
||||||
- name: Build (bundle)
|
- name: Build (bundle)
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: internal
|
||||||
|
area: ci
|
||||||
|
|
||||||
|
- Removed duplicate source and launcher smoke executions from the reusable quality gate while preserving every distinct test lane and failure artifact.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: internal
|
||||||
|
area: verification
|
||||||
|
|
||||||
|
- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check for compiled stats startup, HTTP service, native SQLite, port conflicts, and cleanup.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Stats server startup reports port conflicts without crashing SubMiner, shares concurrent startup requests, and shows in-app startup errors through configured status notifications.
|
||||||
|
- Background stop cancels pending background startup without disconnecting foreground-only dashboards. Shutdown bounds the wait for active HTTP requests and awaits tracker finalization before exit, with a deadline for forced application exit.
|
||||||
@@ -37,6 +37,10 @@ The same immersion data powers the stats dashboard.
|
|||||||
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
|
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
|
||||||
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
|
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
|
||||||
|
|
||||||
|
SubMiner waits for the local server to bind before reporting that the dashboard is available. If another process already uses the configured port, the command reports the startup error and the desktop app stays open. Opening the in-app dashboard also reports startup failures through your configured status notifications.
|
||||||
|
|
||||||
|
`subminer stats -s` stops a background stats server or cancels a pending background start. It leaves a foreground-only server running, so an open in-app dashboard stays connected. Shutdown gives active HTTP requests one second to finish before closing their connections and finalizing stats.
|
||||||
|
|
||||||
### Stats API resource IDs
|
### Stats API resource IDs
|
||||||
|
|
||||||
Resource IDs in URLs must be positive safe integers written as decimal digits without leading zeros, fractions, or exponent notation. ID lists in JSON bodies must contain positive safe integer numbers. Invalid IDs or list entries return `400` before any mutation; bulk requests do not apply just the valid subset. Pagination limits keep their existing rounding and bounds.
|
Resource IDs in URLs must be positive safe integers written as decimal digits without leading zeros, fractions, or exponent notation. ID lists in JSON bodies must contain positive safe integer numbers. Invalid IDs or list entries return `400` before any mutation; bulk requests do not apply just the valid subset. Pagination limits keep their existing rounding and bounds.
|
||||||
|
|||||||
@@ -22,9 +22,15 @@ Read when: selecting the right verification lane for a change
|
|||||||
pull requests, stable tags, and prerelease tags. Keep common quality steps
|
pull requests, stable tags, and prerelease tags. Keep common quality steps
|
||||||
there instead of copying them into caller workflows.
|
there instead of copying them into caller workflows.
|
||||||
- The reusable gate installs Lua and runs `bun run test:env`, so the shipped mpv
|
- The reusable gate installs Lua and runs `bun run test:env`, so the shipped mpv
|
||||||
plugin tests run for every pull request and tagged release.
|
plugin tests and launcher smoke run for every pull request and tagged release.
|
||||||
Lua installation uses only the runner's Ubuntu package sources so unrelated
|
Lua installation uses only the runner's Ubuntu package sources so unrelated
|
||||||
third-party repository failures do not block the gate.
|
third-party repository failures do not block the gate.
|
||||||
|
- In the reusable gate, `test:coverage:src` is also the blocking execution of the
|
||||||
|
discovered `src/**` test lane. The coverage runner returns the failing test's
|
||||||
|
status, so CI does not rerun that lane through `test:fast`. Launcher unit and
|
||||||
|
script tests still run separately because they are outside the coverage lane.
|
||||||
|
- Launcher smoke artifacts are uploaded after `test:env` fails. CI does not rerun
|
||||||
|
launcher smoke solely to collect the same artifacts.
|
||||||
|
|
||||||
## Default Handoff Gate
|
## Default Handoff Gate
|
||||||
|
|
||||||
@@ -49,7 +55,7 @@ bun run docs:build
|
|||||||
- Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb`
|
- Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb`
|
||||||
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
|
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
|
||||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||||
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
- Runtime-compat / compiled behavior after `bun run build`: `bun run test:runtime:compat`
|
||||||
- Stats dashboard UI: `bun run test:stats`
|
- Stats dashboard UI: `bun run test:stats`
|
||||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||||
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
|
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
|
||||||
@@ -62,11 +68,30 @@ bun run docs:build
|
|||||||
|
|
||||||
## Coverage Reporting
|
## Coverage Reporting
|
||||||
|
|
||||||
- `bun run test:coverage:src` runs the maintained `test:src` lane through a sharded coverage runner: one Bun coverage process per test file, then merged LCOV output.
|
- `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as
|
||||||
|
`test:src` through a sharded coverage runner: one Bun coverage process per test
|
||||||
|
file, then merged LCOV output.
|
||||||
|
- A failing coverage shard stops the runner with a nonzero status. Coverage is a
|
||||||
|
source test gate, not a report-only step.
|
||||||
- Machine-readable output lands at `coverage/test-src/lcov.info`.
|
- Machine-readable output lands at `coverage/test-src/lcov.info`.
|
||||||
- Every reusable quality-gate run uploads that LCOV file as the
|
- Every reusable quality-gate run uploads that LCOV file as the
|
||||||
`coverage-test-src` artifact.
|
`coverage-test-src` artifact.
|
||||||
|
|
||||||
|
## Compiled Runtime Smoke
|
||||||
|
|
||||||
|
- `bun run test:smoke:dist` and its `test:runtime:compat` alias require an existing
|
||||||
|
full build and fail with the missing artifact paths when `dist/` or the stats UI
|
||||||
|
bundle is absent.
|
||||||
|
- The check runs the emitted stats daemon under Electron's Node runtime. It opens
|
||||||
|
the production HTTP server, queries the overview endpoint through native
|
||||||
|
libsql-backed storage, and leaves an HTTP request body unfinished before
|
||||||
|
shutting the daemon down. It verifies a clean exit and that the port and
|
||||||
|
ownership state are released despite the unfinished request.
|
||||||
|
- The check also occupies the configured port, requires startup to fail without
|
||||||
|
stale ownership state, releases the conflict, and verifies a clean retry.
|
||||||
|
- This is not a full Electron UI startup check. It does not require a display and
|
||||||
|
makes no claims about renderer, window, tray, or mpv behavior.
|
||||||
|
|
||||||
## Dependency Audit Policy
|
## Dependency Audit Policy
|
||||||
|
|
||||||
- `bun audit --audit-level high` blocks the reusable quality gate.
|
- `bun audit --audit-level high` blocks the reusable quality gate.
|
||||||
|
|||||||
+2
-2
@@ -51,7 +51,7 @@
|
|||||||
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
||||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||||
"test:smoke:dist": "bun scripts/run-test-lane.mjs bun-src-full",
|
"test:smoke:dist": "env ELECTRON_RUN_AS_NODE=1 electron scripts/compiled-runtime-smoke.mjs",
|
||||||
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||||
"test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts",
|
"test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts",
|
||||||
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
|
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
|
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
|
||||||
"test:stats": "bun scripts/run-test-lane.mjs stats",
|
"test:stats": "bun scripts/run-test-lane.mjs stats",
|
||||||
"test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src",
|
"test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src",
|
||||||
"test:runtime:compat": "bun run tsc && bun scripts/run-test-lane.mjs bun-src-full",
|
"test:runtime:compat": "bun run test:smoke:dist",
|
||||||
"test": "bun run test:fast",
|
"test": "bun run test:fast",
|
||||||
"test:config": "bun scripts/run-test-lane.mjs config",
|
"test:config": "bun scripts/run-test-lane.mjs config",
|
||||||
"test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
|
"test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { request as httpRequest } from 'node:http';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const START_TIMEOUT_MS = 15_000;
|
||||||
|
const STOP_TIMEOUT_MS = 5_000;
|
||||||
|
const POLL_INTERVAL_MS = 25;
|
||||||
|
const MAX_CHILD_OUTPUT_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRootArgIndex = process.argv.indexOf('--repo-root');
|
||||||
|
const repoRoot = resolve(
|
||||||
|
repoRootArgIndex === -1 ? join(scriptDir, '..') : (process.argv[repoRootArgIndex + 1] ?? ''),
|
||||||
|
);
|
||||||
|
const paths = {
|
||||||
|
mainEntry: join(repoRoot, 'dist', 'main-entry.js'),
|
||||||
|
daemonEntry: join(repoRoot, 'dist', 'stats-daemon-runner.js'),
|
||||||
|
statsServer: join(repoRoot, 'dist', 'core', 'services', 'stats-server.js'),
|
||||||
|
tracker: join(repoRoot, 'dist', 'core', 'services', 'immersion-tracker-service.js'),
|
||||||
|
statsIndex: join(repoRoot, 'stats', 'dist', 'index.html'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function requireCompiledArtifacts() {
|
||||||
|
const missing = Object.values(paths).filter((artifactPath) => !existsSync(artifactPath));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Compiled runtime artifacts are missing. Run \`bun run build\` before this check:\n${missing
|
||||||
|
.map((artifactPath) => ` - ${artifactPath}`)
|
||||||
|
.join('\n')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireElectronNodeRuntime() {
|
||||||
|
if (!process.versions.electron || process.env.ELECTRON_RUN_AS_NODE !== '1') {
|
||||||
|
throw new Error(
|
||||||
|
'This check must run with Electron in Node mode. Use `bun run test:smoke:dist`.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(server, port = 0) {
|
||||||
|
return new Promise((resolvePromise, reject) => {
|
||||||
|
const onError = (error) => {
|
||||||
|
server.off('listening', onListening);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
const onListening = () => {
|
||||||
|
server.off('error', onError);
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
reject(new Error('Could not resolve the stats smoke port.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolvePromise(address.port);
|
||||||
|
};
|
||||||
|
server.once('error', onError);
|
||||||
|
server.once('listening', onListening);
|
||||||
|
server.listen(port, '127.0.0.1');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeServer(server) {
|
||||||
|
return new Promise((resolvePromise, reject) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolvePromise();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAvailablePort() {
|
||||||
|
const reservation = createServer();
|
||||||
|
const port = await listen(reservation);
|
||||||
|
await closeServer(reservation);
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSmokeConfig(userDataPath, port) {
|
||||||
|
writeFileSync(
|
||||||
|
join(userDataPath, 'config.json'),
|
||||||
|
`${JSON.stringify({ stats: { serverPort: port } })}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnDaemon(userDataPath, responsePath) {
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
paths.daemonEntry,
|
||||||
|
'--stats-user-data-path',
|
||||||
|
userDataPath,
|
||||||
|
'--stats-response-path',
|
||||||
|
responsePath,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let output = '';
|
||||||
|
const appendOutput = (chunk) => {
|
||||||
|
if (output.length >= MAX_CHILD_OUTPUT_BYTES) return;
|
||||||
|
output += String(chunk);
|
||||||
|
if (output.length > MAX_CHILD_OUTPUT_BYTES) {
|
||||||
|
output = `${output.slice(0, MAX_CHILD_OUTPUT_BYTES)}\n[child output truncated]\n`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
child.stdout.on('data', appendOutput);
|
||||||
|
child.stderr.on('data', appendOutput);
|
||||||
|
let exited = false;
|
||||||
|
const exit = new Promise((resolvePromise) => {
|
||||||
|
child.once('error', (error) => {
|
||||||
|
exited = true;
|
||||||
|
resolvePromise({ code: null, signal: null, error });
|
||||||
|
});
|
||||||
|
child.once('exit', (code, signal) => {
|
||||||
|
exited = true;
|
||||||
|
resolvePromise({ code, signal, error: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { child, exit, hasExited: () => exited, getOutput: () => output };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForResponse(responsePath, daemon) {
|
||||||
|
const deadline = Date.now() + START_TIMEOUT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (existsSync(responsePath)) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(responsePath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
// The daemon may still be finishing the response file write.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (daemon.hasExited()) {
|
||||||
|
const result = await daemon.exit;
|
||||||
|
throw new Error(
|
||||||
|
`Stats daemon exited before writing a startup response (${formatExit(result)}).\n${daemon.getOutput()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await delay(POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for stats daemon startup.\n${daemon.getOutput()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExit(result) {
|
||||||
|
if (result.error) return result.error.message;
|
||||||
|
if (result.signal) return `signal ${result.signal}`;
|
||||||
|
return `exit code ${result.code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForExit(daemon, timeoutMs) {
|
||||||
|
return await Promise.race([daemon.exit, delay(timeoutMs).then(() => null)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopDaemon(daemon) {
|
||||||
|
if (daemon.hasExited()) {
|
||||||
|
return await daemon.exit;
|
||||||
|
}
|
||||||
|
daemon.child.kill('SIGTERM');
|
||||||
|
const result = await waitForExit(daemon, STOP_TIMEOUT_MS);
|
||||||
|
if (result) return result;
|
||||||
|
daemon.child.kill('SIGKILL');
|
||||||
|
await daemon.exit;
|
||||||
|
throw new Error(`Stats daemon did not stop after SIGTERM.\n${daemon.getOutput()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertPortCanBind(port) {
|
||||||
|
const server = createServer();
|
||||||
|
try {
|
||||||
|
await listen(server, port);
|
||||||
|
} finally {
|
||||||
|
if (server.listening) await closeServer(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOverview(url, daemon) {
|
||||||
|
const deadline = Date.now() + START_TIMEOUT_MS;
|
||||||
|
let lastError = null;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
return await fetch(`${url}/api/stats/overview`, {
|
||||||
|
signal: AbortSignal.timeout(START_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
if (daemon.hasExited()) {
|
||||||
|
const result = await daemon.exit;
|
||||||
|
throw new Error(
|
||||||
|
`Stats daemon exited before accepting HTTP requests (${formatExit(result)}).\n${daemon.getOutput()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await delay(POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for the stats HTTP server: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${daemon.getOutput()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHealthyStartup(userDataPath, port, responseName) {
|
||||||
|
writeSmokeConfig(userDataPath, port);
|
||||||
|
const responsePath = join(userDataPath, responseName);
|
||||||
|
const statePath = join(userDataPath, 'stats-daemon.json');
|
||||||
|
const databasePath = join(userDataPath, 'immersion.sqlite');
|
||||||
|
const daemon = spawnDaemon(userDataPath, responsePath);
|
||||||
|
let shutdownResult;
|
||||||
|
let unfinishedRequest;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startup = await waitForResponse(responsePath, daemon);
|
||||||
|
assert.deepEqual(startup, { ok: true, url: `http://127.0.0.1:${port}` });
|
||||||
|
|
||||||
|
const response = await fetchOverview(startup.url, daemon);
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.match(response.headers.get('content-type') ?? '', /^application\/json\b/);
|
||||||
|
const overview = await response.json();
|
||||||
|
assert.equal(typeof overview, 'object');
|
||||||
|
assert.ok(overview !== null);
|
||||||
|
assert.ok(Array.isArray(overview.sessions));
|
||||||
|
assert.ok(Array.isArray(overview.rollups));
|
||||||
|
assert.equal(typeof overview.hints, 'object');
|
||||||
|
|
||||||
|
const dashboard = await fetch(`${startup.url}/?overlay=1`, {
|
||||||
|
signal: AbortSignal.timeout(START_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
assert.equal(dashboard.status, 200);
|
||||||
|
assert.match(dashboard.headers.get('content-type') ?? '', /^text\/html\b/);
|
||||||
|
assert.match(await dashboard.text(), /id="root"/);
|
||||||
|
|
||||||
|
assert.ok(existsSync(databasePath), 'The compiled tracker did not create its SQLite database.');
|
||||||
|
assert.ok(
|
||||||
|
statSync(databasePath).size > 0,
|
||||||
|
'The compiled tracker created an empty SQLite file.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Leave a real request body unfinished so shutdown must bound its drain wait.
|
||||||
|
unfinishedRequest = httpRequest(new URL('/api/stats/anki/notesInfo', startup.url), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': '1000',
|
||||||
|
Expect: '100-continue',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(START_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
await new Promise((resolvePromise, reject) => {
|
||||||
|
unfinishedRequest.on('error', reject);
|
||||||
|
unfinishedRequest.once('continue', () => {
|
||||||
|
unfinishedRequest.write('{');
|
||||||
|
resolvePromise();
|
||||||
|
});
|
||||||
|
unfinishedRequest.flushHeaders();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
shutdownResult = await stopDaemon(daemon);
|
||||||
|
} finally {
|
||||||
|
unfinishedRequest?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(shutdownResult.code, 0, `Stats daemon shutdown failed.\n${daemon.getOutput()}`);
|
||||||
|
assert.equal(existsSync(statePath), false, 'Stats daemon state remained after shutdown.');
|
||||||
|
await assertPortCanBind(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runConflictRecovery() {
|
||||||
|
const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-conflict-'));
|
||||||
|
const reservation = createServer();
|
||||||
|
const port = await listen(reservation);
|
||||||
|
const responsePath = join(userDataPath, 'conflict-response.json');
|
||||||
|
const statePath = join(userDataPath, 'stats-daemon.json');
|
||||||
|
writeSmokeConfig(userDataPath, port);
|
||||||
|
const daemon = spawnDaemon(userDataPath, responsePath);
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await waitForResponse(responsePath, daemon);
|
||||||
|
if (response.ok !== false) {
|
||||||
|
failures.push('The stats daemon reported success while its configured port was occupied.');
|
||||||
|
}
|
||||||
|
const result = await waitForExit(daemon, STOP_TIMEOUT_MS);
|
||||||
|
if (!result) {
|
||||||
|
failures.push('The stats daemon did not exit after its configured port failed to bind.');
|
||||||
|
} else if (result.code === 0) {
|
||||||
|
failures.push(
|
||||||
|
'The stats daemon exited successfully after its configured port failed to bind.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (existsSync(statePath)) {
|
||||||
|
failures.push(
|
||||||
|
'The stats daemon left ownership state behind after its configured port failed to bind.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await closeServer(reservation);
|
||||||
|
if (!daemon.hasExited()) {
|
||||||
|
await stopDaemon(daemon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runHealthyStartup(userDataPath, port, 'recovery-response.json');
|
||||||
|
} finally {
|
||||||
|
rmSync(userDataPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(failures, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
requireCompiledArtifacts();
|
||||||
|
requireElectronNodeRuntime();
|
||||||
|
|
||||||
|
const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-runtime-'));
|
||||||
|
try {
|
||||||
|
await runHealthyStartup(userDataPath, await findAvailablePort(), 'startup-response.json');
|
||||||
|
} finally {
|
||||||
|
rmSync(userDataPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
await runConflictRecovery();
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`Compiled runtime smoke passed with Electron ${process.versions.electron}, Node ${process.versions.node}, HTTP, and native SQLite.\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
test('compiled runtime smoke fails clearly when build artifacts are missing', () => {
|
||||||
|
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-compiled-missing-'));
|
||||||
|
try {
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
['scripts/compiled-runtime-smoke.mjs', '--repo-root', emptyRepo],
|
||||||
|
{
|
||||||
|
cwd: path.resolve(import.meta.dir, '..'),
|
||||||
|
encoding: 'utf8',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.stderr, /Compiled runtime artifacts are missing/);
|
||||||
|
assert.match(result.stderr, /dist\/main-entry\.js/);
|
||||||
|
assert.match(result.stderr, /dist\/stats-daemon-runner\.js/);
|
||||||
|
assert.doesNotMatch(result.stderr, /must run with Electron/);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(emptyRepo, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { resolve } from 'node:path';
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { mergeLcovReports, resolveCoverageDir } from './run-coverage-lane';
|
import { mergeLcovReports, resolveCoverageDir, runCoverageLane } from './run-coverage-lane';
|
||||||
|
|
||||||
test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => {
|
test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => {
|
||||||
const merged = mergeLcovReports([
|
const merged = mergeLcovReports([
|
||||||
@@ -72,3 +74,33 @@ test('resolveCoverageDir keeps coverage output inside the repository', () => {
|
|||||||
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '../escape']));
|
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '../escape']));
|
||||||
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
|
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runCoverageLane returns a failure when a discovered test fails', () => {
|
||||||
|
const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-'));
|
||||||
|
try {
|
||||||
|
mkdirSync(join(repoRoot, 'src'));
|
||||||
|
writeFileSync(
|
||||||
|
join(repoRoot, 'src', 'failure.test.ts'),
|
||||||
|
[
|
||||||
|
"import assert from 'node:assert/strict';",
|
||||||
|
"import test from 'node:test';",
|
||||||
|
'',
|
||||||
|
"test('intentional coverage failure', () => {",
|
||||||
|
" assert.fail('coverage runner must propagate this failure');",
|
||||||
|
'});',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.notEqual(
|
||||||
|
runCoverageLane({
|
||||||
|
repoRootDir: repoRoot,
|
||||||
|
argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'],
|
||||||
|
stdio: 'pipe',
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rmSync(repoRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -201,14 +201,18 @@ export function mergeLcovReports(reports: string[]): string {
|
|||||||
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
|
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCoverageLane(): number {
|
export function runCoverageLane(
|
||||||
const laneName = process.argv[2];
|
options: { repoRootDir?: string; argv?: string[]; stdio?: 'inherit' | 'pipe' } = {},
|
||||||
|
): number {
|
||||||
|
const repoRootDir = options.repoRootDir ?? repoRoot;
|
||||||
|
const argv = options.argv ?? process.argv.slice(2);
|
||||||
|
const laneName = argv[0];
|
||||||
if (laneName === undefined) {
|
if (laneName === undefined) {
|
||||||
process.stderr.write('Missing coverage lane name\n');
|
process.stderr.write('Missing coverage lane name\n');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverageDir = resolveCoverageDir(repoRoot, process.argv.slice(3));
|
const coverageDir = resolveCoverageDir(repoRootDir, argv.slice(1));
|
||||||
const shardRoot = join(coverageDir, '.shards');
|
const shardRoot = join(coverageDir, '.shards');
|
||||||
mkdirSync(coverageDir, { recursive: true });
|
mkdirSync(coverageDir, { recursive: true });
|
||||||
rmSync(shardRoot, { recursive: true, force: true });
|
rmSync(shardRoot, { recursive: true, force: true });
|
||||||
@@ -216,7 +220,7 @@ function runCoverageLane(): number {
|
|||||||
|
|
||||||
let files: string[];
|
let files: string[];
|
||||||
try {
|
try {
|
||||||
files = collectLaneFiles(repoRoot, laneName);
|
files = collectLaneFiles(repoRootDir, laneName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
@@ -230,8 +234,8 @@ function runCoverageLane(): number {
|
|||||||
'bun',
|
'bun',
|
||||||
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
|
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
|
||||||
{
|
{
|
||||||
cwd: repoRoot,
|
cwd: repoRootDir,
|
||||||
stdio: 'inherit',
|
stdio: options.stdio ?? 'inherit',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -253,7 +257,7 @@ function runCoverageLane(): number {
|
|||||||
|
|
||||||
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
|
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`,
|
`Merged LCOV written to ${relative(repoRootDir, join(coverageDir, 'lcov.info'))}\n`,
|
||||||
);
|
);
|
||||||
return 0;
|
return 0;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -19,6 +19,23 @@ test('package scripts expose a sharded maintained source coverage lane with lcov
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('source and coverage scripts discover the same maintained source lane', () => {
|
||||||
|
const sourceLane = packageJson.scripts['test:src']?.match(/run-test-lane\.mjs\s+([^\s]+)/)?.[1];
|
||||||
|
const coverageLane = packageJson.scripts['test:coverage:src']?.match(
|
||||||
|
/run-coverage-lane\.ts\s+([^\s]+)/,
|
||||||
|
)?.[1];
|
||||||
|
|
||||||
|
assert.equal(sourceLane, 'bun-src-full');
|
||||||
|
assert.equal(coverageLane, sourceLane);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('environment suite owns launcher smoke execution', () => {
|
||||||
|
assert.match(
|
||||||
|
packageJson.scripts['test:env'] ?? '',
|
||||||
|
/^bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('ci delegates its gate instead of duplicating quality steps', () => {
|
test('ci delegates its gate instead of duplicating quality steps', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
ciWorkflow,
|
ciWorkflow,
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import http from 'node:http';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { AddressInfo } from 'node:net';
|
import type { AddressInfo } from 'node:net';
|
||||||
import { createStatsApp, startStatsServer } from '../stats-server.js';
|
import {
|
||||||
|
createStatsApp,
|
||||||
|
startNodeHttpServer,
|
||||||
|
startStatsServerWithRuntime,
|
||||||
|
} from '../stats-server.js';
|
||||||
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
||||||
import {
|
import {
|
||||||
clearRetimedSecondarySubtitleCache,
|
clearRetimedSecondarySubtitleCache,
|
||||||
@@ -3995,102 +3999,133 @@ Aligned English subtitle
|
|||||||
assert.equal(ensureCalls, 1);
|
assert.equal(ensureCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts the stats server with Bun.serve', () => {
|
it('starts and stops the stats server with Bun.serve', async () => {
|
||||||
type BunRuntime = {
|
const servedOptions: Array<{ fetch: unknown; port: number; hostname: string }> = [];
|
||||||
Bun: {
|
|
||||||
serve: (options: { fetch: unknown; port: number; hostname: string }) => {
|
|
||||||
stop: () => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const bun = globalThis as typeof globalThis & BunRuntime;
|
|
||||||
const originalServe = bun.Bun.serve;
|
|
||||||
let servedWith: { fetch: unknown; port: number; hostname: string } | null = null;
|
|
||||||
let stopCalls = 0;
|
let stopCalls = 0;
|
||||||
|
const server = await startStatsServerWithRuntime(
|
||||||
bun.Bun.serve = (options: { fetch: unknown; port: number; hostname: string }) => {
|
{
|
||||||
servedWith = options;
|
port: 3210,
|
||||||
|
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-start-')),
|
||||||
|
tracker: createMockTracker(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bunServe: (options) => {
|
||||||
|
servedOptions.push(options);
|
||||||
return {
|
return {
|
||||||
stop: () => {
|
stop: () => {
|
||||||
stopCalls += 1;
|
stopCalls += 1;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
const servedWith = servedOptions[0];
|
||||||
const server = startStatsServer({
|
if (!servedWith) {
|
||||||
port: 3210,
|
|
||||||
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-start-')),
|
|
||||||
tracker: createMockTracker(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (servedWith === null) {
|
|
||||||
throw new Error('expected Bun.serve to be called');
|
throw new Error('expected Bun.serve to be called');
|
||||||
}
|
}
|
||||||
|
|
||||||
const servedOptions = servedWith as {
|
assert.equal(servedWith.port, 3210);
|
||||||
fetch: unknown;
|
assert.equal(servedWith.hostname, '127.0.0.1');
|
||||||
port: number;
|
assert.equal(typeof servedWith.fetch, 'function');
|
||||||
hostname: string;
|
|
||||||
};
|
|
||||||
assert.equal(servedOptions.port, 3210);
|
|
||||||
assert.equal(servedOptions.hostname, '127.0.0.1');
|
|
||||||
assert.equal(typeof servedOptions.fetch, 'function');
|
|
||||||
|
|
||||||
server.close();
|
await Promise.all([server.close(), server.close()]);
|
||||||
assert.equal(stopCalls, 1);
|
assert.equal(stopCalls, 1);
|
||||||
} finally {
|
|
||||||
bun.Bun.serve = originalServe;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to node:http when Bun.serve is unavailable', () => {
|
it('waits for node:http listening and converts startup errors into rejections', async () => {
|
||||||
type BunRuntime = {
|
const app = createStatsApp(createMockTracker());
|
||||||
Bun: {
|
const listeningServer = http.createServer();
|
||||||
serve?: (options: { fetch: unknown; port: number; hostname: string }) => {
|
|
||||||
stop: () => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const bun = globalThis as typeof globalThis & BunRuntime;
|
|
||||||
const originalServe = bun.Bun.serve;
|
|
||||||
const originalCreateServer = http.createServer;
|
|
||||||
let listenedWith: { port: number; hostname: string } | null = null;
|
|
||||||
let closeCalls = 0;
|
let closeCalls = 0;
|
||||||
bun.Bun.serve = undefined;
|
Object.defineProperties(listeningServer, {
|
||||||
(
|
listen: {
|
||||||
http as typeof http & {
|
value: () => listeningServer,
|
||||||
createServer: typeof http.createServer;
|
|
||||||
}
|
|
||||||
).createServer = (() =>
|
|
||||||
({
|
|
||||||
listen: (port: number, hostname: string) => {
|
|
||||||
listenedWith = { port, hostname };
|
|
||||||
},
|
},
|
||||||
close: () => {
|
close: {
|
||||||
|
value: (callback?: (error?: Error) => void) => {
|
||||||
closeCalls += 1;
|
closeCalls += 1;
|
||||||
|
callback?.();
|
||||||
|
return listeningServer;
|
||||||
},
|
},
|
||||||
}) as unknown as ReturnType<typeof http.createServer>) as typeof http.createServer;
|
},
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
let startupSettled = false;
|
||||||
const server = startStatsServer({
|
const startup = startNodeHttpServer(
|
||||||
|
app,
|
||||||
|
{
|
||||||
|
port: 3210,
|
||||||
|
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-events-')),
|
||||||
|
tracker: createMockTracker(),
|
||||||
|
},
|
||||||
|
() => listeningServer,
|
||||||
|
);
|
||||||
|
void startup.finally(() => {
|
||||||
|
startupSettled = true;
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(startupSettled, false);
|
||||||
|
|
||||||
|
listeningServer.emit('listening');
|
||||||
|
const handle = await startup;
|
||||||
|
await Promise.all([handle.close(), handle.close()]);
|
||||||
|
assert.equal(closeCalls, 1);
|
||||||
|
|
||||||
|
const failingServer = http.createServer();
|
||||||
|
Object.defineProperty(failingServer, 'listen', {
|
||||||
|
value: () => failingServer,
|
||||||
|
});
|
||||||
|
const failedStartup = startNodeHttpServer(
|
||||||
|
app,
|
||||||
|
{
|
||||||
|
port: 3210,
|
||||||
|
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-error-')),
|
||||||
|
tracker: createMockTracker(),
|
||||||
|
},
|
||||||
|
() => failingServer,
|
||||||
|
);
|
||||||
|
failingServer.emit('error', Object.assign(new Error('address in use'), { code: 'EADDRINUSE' }));
|
||||||
|
await assert.rejects(
|
||||||
|
failedStartup,
|
||||||
|
(error: NodeJS.ErrnoException) => error.code === 'EADDRINUSE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts, rejects address conflicts, and stops through real node:http sockets', async () => {
|
||||||
|
const app = createStatsApp(createMockTracker());
|
||||||
|
const server = await startNodeHttpServer(app, {
|
||||||
port: 0,
|
port: 0,
|
||||||
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-')),
|
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-')),
|
||||||
tracker: createMockTracker(),
|
tracker: createMockTracker(),
|
||||||
});
|
});
|
||||||
|
await Promise.all([server.close(), server.close()]);
|
||||||
|
|
||||||
assert.deepEqual(listenedWith, { port: 0, hostname: '127.0.0.1' });
|
const blocker = http.createServer();
|
||||||
server.close();
|
await new Promise<void>((resolve, reject) => {
|
||||||
assert.equal(closeCalls, 1);
|
blocker.once('error', reject);
|
||||||
|
blocker.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
const address = blocker.address();
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
throw new Error('expected blocker to listen on a TCP port');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
startNodeHttpServer(app, {
|
||||||
|
port: address.port,
|
||||||
|
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-error-')),
|
||||||
|
tracker: createMockTracker(),
|
||||||
|
}),
|
||||||
|
(error: NodeJS.ErrnoException) => error.code === 'EADDRINUSE',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
bun.Bun.serve = originalServe;
|
await new Promise<void>((resolve, reject) => {
|
||||||
(
|
blocker.close((error) => {
|
||||||
http as typeof http & {
|
if (error) reject(error);
|
||||||
createServer: typeof http.createServer;
|
else resolve();
|
||||||
}
|
});
|
||||||
).createServer = originalCreateServer;
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -406,6 +406,17 @@ test('handleCliCommand ensures background stats server for second-instance --sta
|
|||||||
assert.equal(ensured.length, 1);
|
assert.equal(ensured.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handleCliCommand reports unexpected background stats startup failures', async () => {
|
||||||
|
const startup = Promise.reject(new Error('startup unavailable'));
|
||||||
|
const { deps, calls, osd } = createDeps({ ensureBackgroundStatsServer: () => startup });
|
||||||
|
|
||||||
|
handleCliCommand(makeArgs({ start: true, background: true }), 'initial', deps);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.ok(calls.includes('error:ensureBackgroundStatsServer failed:'));
|
||||||
|
assert.ok(osd.includes('Stats server startup failed: startup unavailable'));
|
||||||
|
});
|
||||||
|
|
||||||
test('handleCliCommand does not ensure background stats server for foreground --start', () => {
|
test('handleCliCommand does not ensure background stats server for foreground --start', () => {
|
||||||
const ensured: number[] = [];
|
const ensured: number[] = [];
|
||||||
const { deps } = createDeps({
|
const { deps } = createDeps({
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export interface CliCommandServiceDeps {
|
|||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||||
source: CliCommandSource;
|
source: CliCommandSource;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
ensureBackgroundStatsServer?: () => void;
|
ensureBackgroundStatsServer?: () => Promise<void> | void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
hasMainWindow: () => boolean;
|
hasMainWindow: () => boolean;
|
||||||
getMultiCopyTimeoutMs: () => number;
|
getMultiCopyTimeoutMs: () => number;
|
||||||
@@ -188,7 +188,7 @@ interface AnilistCliRuntime {
|
|||||||
interface AppCliRuntime {
|
interface AppCliRuntime {
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
hasMainWindow: () => boolean;
|
hasMainWindow: () => boolean;
|
||||||
ensureBackgroundStatsServer?: () => void;
|
ensureBackgroundStatsServer?: () => Promise<void> | void;
|
||||||
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
||||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
||||||
@@ -400,7 +400,14 @@ export function handleCliCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.start && args.background) {
|
if (args.start && args.background) {
|
||||||
deps.ensureBackgroundStatsServer?.();
|
runAsyncWithOsd(
|
||||||
|
async () => {
|
||||||
|
await deps.ensureBackgroundStatsServer?.();
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
'ensureBackgroundStatsServer',
|
||||||
|
'Stats server startup failed',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.sessionAction) {
|
if (args.sessionAction) {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { dispatchSessionAction, type SessionActionExecutorDeps } from './session
|
|||||||
function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const deps: SessionActionExecutorDeps = {
|
const deps: SessionActionExecutorDeps = {
|
||||||
toggleStatsOverlay: () => calls.push('stats'),
|
toggleStatsOverlay: () => {
|
||||||
|
calls.push('stats');
|
||||||
|
},
|
||||||
toggleVisibleOverlay: () => calls.push('visible'),
|
toggleVisibleOverlay: () => calls.push('visible'),
|
||||||
copyCurrentSubtitle: () => calls.push('copy'),
|
copyCurrentSubtitle: () => calls.push('copy'),
|
||||||
copySubtitleCount: (count) => calls.push(`copy:${count}`),
|
copySubtitleCount: (count) => calls.push(`copy:${count}`),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { SessionActionId } from '../../types/session-bindings';
|
|||||||
import type { SessionActionDispatchRequest } from '../../types/runtime';
|
import type { SessionActionDispatchRequest } from '../../types/runtime';
|
||||||
|
|
||||||
export interface SessionActionExecutorDeps {
|
export interface SessionActionExecutorDeps {
|
||||||
toggleStatsOverlay: () => void;
|
toggleStatsOverlay: () => Promise<void> | void;
|
||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
copyCurrentSubtitle: () => void;
|
copyCurrentSubtitle: () => void;
|
||||||
copySubtitleCount: (count: number) => void;
|
copySubtitleCount: (count: number) => void;
|
||||||
@@ -50,7 +50,7 @@ export async function dispatchSessionAction(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
switch (request.actionId) {
|
switch (request.actionId) {
|
||||||
case 'toggleStatsOverlay':
|
case 'toggleStatsOverlay':
|
||||||
deps.toggleStatsOverlay();
|
await deps.toggleStatsOverlay();
|
||||||
return;
|
return;
|
||||||
case 'toggleVisibleOverlay':
|
case 'toggleVisibleOverlay':
|
||||||
deps.toggleVisibleOverlay();
|
deps.toggleVisibleOverlay();
|
||||||
|
|||||||
@@ -50,8 +50,26 @@ async function writeFetchResponse(res: ServerResponse, response: Response): Prom
|
|||||||
res.end(Buffer.from(await response.arrayBuffer()));
|
res.end(Buffer.from(await response.arrayBuffer()));
|
||||||
}
|
}
|
||||||
|
|
||||||
function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } {
|
export interface StatsServer {
|
||||||
const server = http.createServer((req, res) => {
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHUTDOWN_GRACE_MS = 1_000;
|
||||||
|
|
||||||
|
type BunServe = (options: {
|
||||||
|
fetch: (typeof Hono.prototype)['fetch'];
|
||||||
|
port: number;
|
||||||
|
hostname: string;
|
||||||
|
}) => {
|
||||||
|
stop: () => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startNodeHttpServer(
|
||||||
|
app: Hono,
|
||||||
|
config: StatsServerConfig,
|
||||||
|
createServer: (listener: http.RequestListener) => http.Server = http.createServer,
|
||||||
|
): Promise<StatsServer> {
|
||||||
|
const server = createServer((req, res) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await writeFetchResponse(res, await app.fetch(toFetchRequest(req)));
|
await writeFetchResponse(res, await app.fetch(toFetchRequest(req)));
|
||||||
@@ -61,12 +79,33 @@ function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: ()
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
server.listen(config.port, '127.0.0.1');
|
return new Promise((resolve, reject) => {
|
||||||
return {
|
const handleStartupError = (error: Error): void => {
|
||||||
close: () => {
|
server.removeListener('listening', handleListening);
|
||||||
server.close();
|
reject(error);
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
const handleListening = (): void => {
|
||||||
|
server.removeListener('error', handleStartupError);
|
||||||
|
let closePromise: Promise<void> | null = null;
|
||||||
|
resolve({
|
||||||
|
close: () => {
|
||||||
|
closePromise ??= new Promise<void>((closeResolve, closeReject) => {
|
||||||
|
const forceClose = setTimeout(() => server.closeAllConnections(), SHUTDOWN_GRACE_MS);
|
||||||
|
server.close((error) => {
|
||||||
|
clearTimeout(forceClose);
|
||||||
|
if (error) closeReject(error);
|
||||||
|
else closeResolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return closePromise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
server.once('error', handleStartupError);
|
||||||
|
server.once('listening', handleListening);
|
||||||
|
server.listen(config.port, '127.0.0.1');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatsServerConfig {
|
export interface StatsServerConfig {
|
||||||
@@ -125,7 +164,10 @@ export function createStatsApp(
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startStatsServer(config: StatsServerConfig): { close: () => void } {
|
export async function startStatsServerWithRuntime(
|
||||||
|
config: StatsServerConfig,
|
||||||
|
runtime: { bunServe: BunServe | null },
|
||||||
|
): Promise<StatsServer> {
|
||||||
const app = createStatsApp(config.tracker, {
|
const app = createStatsApp(config.tracker, {
|
||||||
staticDir: config.staticDir,
|
staticDir: config.staticDir,
|
||||||
knownWordCachePath: config.knownWordCachePath,
|
knownWordCachePath: config.knownWordCachePath,
|
||||||
@@ -144,20 +186,26 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
|||||||
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
|
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bunRuntime = globalThis as typeof globalThis & {
|
if (runtime.bunServe) {
|
||||||
Bun?: {
|
const server = runtime.bunServe({
|
||||||
serve?: (options: { fetch: (typeof app)['fetch']; port: number; hostname: string }) => {
|
|
||||||
stop: () => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
if (bunRuntime.Bun?.serve) {
|
|
||||||
const server = bunRuntime.Bun.serve({
|
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
hostname: '127.0.0.1',
|
hostname: '127.0.0.1',
|
||||||
});
|
});
|
||||||
return { close: () => server.stop() };
|
let closePromise: Promise<void> | null = null;
|
||||||
|
return Promise.resolve({
|
||||||
|
close: () => {
|
||||||
|
closePromise ??= Promise.resolve().then(() => server.stop());
|
||||||
|
return closePromise;
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return startNodeHttpServer(app, config);
|
return startNodeHttpServer(app, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startStatsServer(config: StatsServerConfig): Promise<StatsServer> {
|
||||||
|
const bunRuntime = globalThis as typeof globalThis & {
|
||||||
|
Bun?: { serve?: BunServe };
|
||||||
|
};
|
||||||
|
return startStatsServerWithRuntime(config, { bunServe: bunRuntime.Bun?.serve ?? null });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BrowserWindow, dialog, ipcMain } from 'electron';
|
import { BrowserWindow, dialog, ipcMain } from 'electron';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { createLogger } from '../../logger.js';
|
||||||
import type { WindowGeometry } from '../../types.js';
|
import type { WindowGeometry } from '../../types.js';
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
||||||
import {
|
import {
|
||||||
@@ -26,9 +27,11 @@ import {
|
|||||||
} from './stats-window-layer.js';
|
} from './stats-window-layer.js';
|
||||||
|
|
||||||
let statsWindow: BrowserWindow | null = null;
|
let statsWindow: BrowserWindow | null = null;
|
||||||
|
let statsWindowGeneration = 0;
|
||||||
let toggleRegistered = false;
|
let toggleRegistered = false;
|
||||||
let nativeDialogLayerRegistered = false;
|
let nativeDialogLayerRegistered = false;
|
||||||
const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState();
|
const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState();
|
||||||
|
const logger = createLogger('main:stats-window');
|
||||||
|
|
||||||
export interface StatsWindowOptions {
|
export interface StatsWindowOptions {
|
||||||
/** Absolute path to stats/dist/ directory */
|
/** Absolute path to stats/dist/ directory */
|
||||||
@@ -36,7 +39,9 @@ export interface StatsWindowOptions {
|
|||||||
/** Absolute path to the compiled preload-stats.js */
|
/** Absolute path to the compiled preload-stats.js */
|
||||||
preloadPath: string;
|
preloadPath: string;
|
||||||
/** Resolve the active stats API base URL */
|
/** Resolve the active stats API base URL */
|
||||||
getApiBaseUrl?: () => string;
|
getApiBaseUrl?: () => Promise<string> | string;
|
||||||
|
/** Report server startup failure through the configured notification surface. */
|
||||||
|
onStartupError?: (error: unknown) => void;
|
||||||
/** Resolve the active stats toggle key from config */
|
/** Resolve the active stats toggle key from config */
|
||||||
getToggleKey: () => string;
|
getToggleKey: () => string;
|
||||||
/** Resolve the tracked overlay/mpv bounds */
|
/** Resolve the tracked overlay/mpv bounds */
|
||||||
@@ -179,8 +184,16 @@ function registerStatsNativeDialogLayerHandlers(): void {
|
|||||||
* Toggle the stats overlay window: create on first call, then show/hide.
|
* Toggle the stats overlay window: create on first call, then show/hide.
|
||||||
* The React app stays mounted across toggles — state is preserved.
|
* The React app stays mounted across toggles — state is preserved.
|
||||||
*/
|
*/
|
||||||
export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
export async function toggleStatsOverlay(options: StatsWindowOptions): Promise<void> {
|
||||||
if (!statsWindow) {
|
if (!statsWindow) {
|
||||||
|
const generation = statsWindowGeneration;
|
||||||
|
const apiBaseUrl = await Promise.resolve()
|
||||||
|
.then(() => options.getApiBaseUrl?.())
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
options.onStartupError?.(error);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
if (generation !== statsWindowGeneration || statsWindow) return;
|
||||||
statsWindow = new BrowserWindow(
|
statsWindow = new BrowserWindow(
|
||||||
buildStatsWindowOptions({
|
buildStatsWindowOptions({
|
||||||
preloadPath: options.preloadPath,
|
preloadPath: options.preloadPath,
|
||||||
@@ -195,7 +208,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const indexPath = path.join(options.staticDir, 'index.html');
|
const indexPath = path.join(options.staticDir, 'index.html');
|
||||||
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(options.getApiBaseUrl?.()));
|
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(apiBaseUrl));
|
||||||
|
|
||||||
statsWindow.on('closed', () => {
|
statsWindow.on('closed', () => {
|
||||||
options.onVisibilityChanged?.(false);
|
options.onVisibilityChanged?.(false);
|
||||||
@@ -243,7 +256,9 @@ export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
|
|||||||
if (toggleRegistered) return;
|
if (toggleRegistered) return;
|
||||||
toggleRegistered = true;
|
toggleRegistered = true;
|
||||||
ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => {
|
ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => {
|
||||||
toggleStatsOverlay(options);
|
void toggleStatsOverlay(options).catch((error: unknown) => {
|
||||||
|
logger.error('Failed to open stats overlay:', error);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +267,7 @@ export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
|
|||||||
* Call during app quit.
|
* Call during app quit.
|
||||||
*/
|
*/
|
||||||
export function destroyStatsWindow(): void {
|
export function destroyStatsWindow(): void {
|
||||||
|
statsWindowGeneration += 1;
|
||||||
if (statsWindow && !statsWindow.isDestroyed()) {
|
if (statsWindow && !statsWindow.isDestroyed()) {
|
||||||
statsWindow.destroy();
|
statsWindow.destroy();
|
||||||
statsWindow = null;
|
statsWindow = null;
|
||||||
|
|||||||
+31
-10
@@ -421,6 +421,7 @@ import {
|
|||||||
writeStatsCliCommandResponse,
|
writeStatsCliCommandResponse,
|
||||||
} from './main/runtime/stats-cli-command';
|
} from './main/runtime/stats-cli-command';
|
||||||
import { createStatsServerRuntime } from './main/runtime/stats-server-runtime';
|
import { createStatsServerRuntime } from './main/runtime/stats-server-runtime';
|
||||||
|
import { createForceQuitHandler } from './main/runtime/app-lifecycle-actions';
|
||||||
import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos';
|
import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos';
|
||||||
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
|
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
|
||||||
import {
|
import {
|
||||||
@@ -1010,11 +1011,17 @@ function requestAppQuit(): void {
|
|||||||
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
|
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
|
||||||
appState.yomitanSettingsWindow = null;
|
appState.yomitanSettingsWindow = null;
|
||||||
destroyStatsWindow();
|
destroyStatsWindow();
|
||||||
stopStatsServer();
|
void stopStatsServer().catch((error: unknown) => {
|
||||||
|
logger.warn('Failed to stop stats server while quitting.', error);
|
||||||
|
});
|
||||||
if (!forceQuitTimer) {
|
if (!forceQuitTimer) {
|
||||||
forceQuitTimer = setTimeout(() => {
|
forceQuitTimer = setTimeout(() => {
|
||||||
logger.warn('App quit timed out; forcing process exit.');
|
logger.warn('App quit timed out; forcing process exit.');
|
||||||
app.exit(0);
|
void createForceQuitHandler({
|
||||||
|
destroyImmersionTracker: () => appState.immersionTracker?.destroy(),
|
||||||
|
logError: (error) => logger.error('Failed to finalize stats before forced exit.', error),
|
||||||
|
exit: () => app.exit(0),
|
||||||
|
})();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
app.quit();
|
app.quit();
|
||||||
@@ -4005,8 +4012,8 @@ const {
|
|||||||
},
|
},
|
||||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||||
getImmersionTracker: () => appState.immersionTracker,
|
getImmersionTracker: () => appState.immersionTracker,
|
||||||
|
stopStatsServer: () => stopStatsServer(),
|
||||||
clearImmersionTracker: () => {
|
clearImmersionTracker: () => {
|
||||||
stopStatsServer();
|
|
||||||
appState.statsServer = null;
|
appState.statsServer = null;
|
||||||
appState.immersionTracker = null;
|
appState.immersionTracker = null;
|
||||||
},
|
},
|
||||||
@@ -4095,7 +4102,9 @@ const immersionTrackerStartupMainDeps: Parameters<
|
|||||||
const trackerHasChanged =
|
const trackerHasChanged =
|
||||||
appState.immersionTracker !== null && appState.immersionTracker !== tracker;
|
appState.immersionTracker !== null && appState.immersionTracker !== tracker;
|
||||||
if (trackerHasChanged && appState.statsServer) {
|
if (trackerHasChanged && appState.statsServer) {
|
||||||
stopStatsServer();
|
void stopStatsServer().catch((error: unknown) => {
|
||||||
|
logger.warn('Failed to stop stats server while replacing immersion tracker.', error);
|
||||||
|
});
|
||||||
appState.statsServer = null;
|
appState.statsServer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4106,7 +4115,9 @@ const immersionTrackerStartupMainDeps: Parameters<
|
|||||||
if (!appState.statsServer) {
|
if (!appState.statsServer) {
|
||||||
const config = configService.getConfig();
|
const config = configService.getConfig();
|
||||||
if (config.stats.autoStartServer) {
|
if (config.stats.autoStartServer) {
|
||||||
ensureStatsServerStarted();
|
void ensureStatsServerStarted().catch((error: unknown) => {
|
||||||
|
logger.warn('Failed to auto-start stats server.', error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4114,7 +4125,12 @@ const immersionTrackerStartupMainDeps: Parameters<
|
|||||||
registerStatsOverlayToggle({
|
registerStatsOverlayToggle({
|
||||||
staticDir: statsDistPath,
|
staticDir: statsDistPath,
|
||||||
preloadPath: statsPreloadPath,
|
preloadPath: statsPreloadPath,
|
||||||
getApiBaseUrl: () => ensureStatsServerStarted().url,
|
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
|
||||||
|
onStartupError: (error) =>
|
||||||
|
overlayNotificationsRuntime.showConfiguredStatusNotification(
|
||||||
|
`Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
{ title: 'Stats' },
|
||||||
|
),
|
||||||
getToggleKey: () => configService.getConfig().stats.toggleKey,
|
getToggleKey: () => configService.getConfig().stats.toggleKey,
|
||||||
resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(),
|
resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(),
|
||||||
onVisibilityChanged: (visible) => {
|
onVisibilityChanged: (visible) => {
|
||||||
@@ -4196,7 +4212,7 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
|
|||||||
await createMecabTokenizerAndCheck();
|
await createMecabTokenizerAndCheck();
|
||||||
},
|
},
|
||||||
getImmersionTracker: () => appState.immersionTracker,
|
getImmersionTracker: () => appState.immersionTracker,
|
||||||
ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted().url,
|
ensureStatsServerStarted: async () => (await statsStartupRuntime.ensureStatsServerStarted()).url,
|
||||||
ensureBackgroundStatsServerStarted: () =>
|
ensureBackgroundStatsServerStarted: () =>
|
||||||
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
|
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
|
||||||
stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(),
|
stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(),
|
||||||
@@ -5488,11 +5504,16 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand
|
|||||||
|
|
||||||
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
||||||
await dispatchSessionActionCore(request, {
|
await dispatchSessionActionCore(request, {
|
||||||
toggleStatsOverlay: () =>
|
toggleStatsOverlay: async () =>
|
||||||
toggleStatsOverlayWindow({
|
await toggleStatsOverlayWindow({
|
||||||
staticDir: statsDistPath,
|
staticDir: statsDistPath,
|
||||||
preloadPath: statsPreloadPath,
|
preloadPath: statsPreloadPath,
|
||||||
getApiBaseUrl: () => ensureStatsServerStarted().url,
|
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
|
||||||
|
onStartupError: (error) =>
|
||||||
|
overlayNotificationsRuntime.showConfiguredStatusNotification(
|
||||||
|
`Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
{ title: 'Stats' },
|
||||||
|
),
|
||||||
getToggleKey: () => configService.getConfig().stats.toggleKey,
|
getToggleKey: () => configService.getConfig().stats.toggleKey,
|
||||||
resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(),
|
resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(),
|
||||||
onVisibilityChanged: (visible) => {
|
onVisibilityChanged: (visible) => {
|
||||||
|
|||||||
@@ -433,10 +433,10 @@ test('warm tokenization release can signal readiness before the first subtitle a
|
|||||||
|
|
||||||
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
||||||
const source = readSource('src/main/runtime/stats-server-runtime.ts');
|
const source = readSource('src/main/runtime/stats-server-runtime.ts');
|
||||||
const startStatsServerBlock = source.match(
|
const statsServerConfigBlock = source.match(
|
||||||
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
/const buildStatsServerConfig[\s\S]*?return \{(?<body>[\s\S]*?)\n \};\n \};/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
const addYomitanNoteBlock = statsServerConfigBlock?.match(
|
||||||
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,32 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
createForceQuitHandler,
|
||||||
createOnWillQuitCleanupHandler,
|
createOnWillQuitCleanupHandler,
|
||||||
createRestoreWindowsOnActivateHandler,
|
createRestoreWindowsOnActivateHandler,
|
||||||
createShouldRestoreWindowsOnActivateHandler,
|
createShouldRestoreWindowsOnActivateHandler,
|
||||||
} from './app-lifecycle-actions';
|
} from './app-lifecycle-actions';
|
||||||
|
|
||||||
test('on will quit cleanup handler runs all cleanup steps', () => {
|
test('forced quit finalizes stats before exiting, even when finalization throws', async () => {
|
||||||
|
for (const fails of [false, true]) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
await createForceQuitHandler({
|
||||||
|
destroyImmersionTracker: () => {
|
||||||
|
calls.push('finalize');
|
||||||
|
if (fails) throw new Error('flush failed');
|
||||||
|
},
|
||||||
|
logError: () => {
|
||||||
|
calls.push('error');
|
||||||
|
},
|
||||||
|
exit: () => {
|
||||||
|
calls.push('exit');
|
||||||
|
},
|
||||||
|
})();
|
||||||
|
assert.deepEqual(calls, fails ? ['finalize', 'error', 'exit'] : ['finalize', 'exit']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on will quit cleanup handler runs all cleanup steps', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const cleanup = createOnWillQuitCleanupHandler({
|
const cleanup = createOnWillQuitCleanupHandler({
|
||||||
destroyTray: () => calls.push('destroy-tray'),
|
destroyTray: () => calls.push('destroy-tray'),
|
||||||
@@ -32,7 +52,15 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
destroyMpvSocket: () => calls.push('destroy-socket'),
|
destroyMpvSocket: () => calls.push('destroy-socket'),
|
||||||
clearReconnectTimer: () => calls.push('clear-reconnect'),
|
clearReconnectTimer: () => calls.push('clear-reconnect'),
|
||||||
destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'),
|
destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'),
|
||||||
destroyImmersionTracker: () => calls.push('destroy-immersion'),
|
stopStatsServer: async () => {
|
||||||
|
calls.push('stop-stats-server-start');
|
||||||
|
await Promise.resolve();
|
||||||
|
calls.push('stop-stats-server-complete');
|
||||||
|
},
|
||||||
|
destroyImmersionTracker: async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
calls.push('destroy-immersion');
|
||||||
|
},
|
||||||
destroyAnkiIntegration: () => calls.push('destroy-anki'),
|
destroyAnkiIntegration: () => calls.push('destroy-anki'),
|
||||||
destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'),
|
destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'),
|
||||||
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
||||||
@@ -51,8 +79,8 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||||
});
|
});
|
||||||
|
|
||||||
cleanup();
|
await cleanup();
|
||||||
assert.equal(calls.length, 36);
|
assert.equal(calls.length, 38);
|
||||||
assert.equal(calls[0], 'destroy-tray');
|
assert.equal(calls[0], 'destroy-tray');
|
||||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||||
@@ -63,9 +91,37 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
assert.ok(calls.includes('cleanup-youtube-media'));
|
assert.ok(calls.includes('cleanup-youtube-media'));
|
||||||
assert.ok(calls.includes('cleanup-remote-media-windows'));
|
assert.ok(calls.includes('cleanup-remote-media-windows'));
|
||||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||||
|
assert.ok(calls.indexOf('stop-stats-server-complete') < calls.indexOf('destroy-immersion'));
|
||||||
|
assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('destroy-anki'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => {
|
test('forced quit waits for asynchronous stats finalization', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
await createForceQuitHandler({
|
||||||
|
destroyImmersionTracker: async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
calls.push('finalized');
|
||||||
|
},
|
||||||
|
logError: () => calls.push('error'),
|
||||||
|
exit: () => calls.push('exit'),
|
||||||
|
})();
|
||||||
|
assert.deepEqual(calls, ['finalized', 'exit']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forced quit exits when asynchronous stats finalization never settles', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
await createForceQuitHandler({
|
||||||
|
destroyImmersionTracker: () => new Promise<void>(() => {}),
|
||||||
|
logError: (error) => {
|
||||||
|
assert.match(String(error), /Stats finalization timed out/);
|
||||||
|
calls.push('timeout');
|
||||||
|
},
|
||||||
|
exit: () => calls.push('exit'),
|
||||||
|
})();
|
||||||
|
assert.deepEqual(calls, ['timeout', 'exit']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const cleanup = createOnWillQuitCleanupHandler({
|
const cleanup = createOnWillQuitCleanupHandler({
|
||||||
destroyTray: () => {},
|
destroyTray: () => {},
|
||||||
@@ -87,6 +143,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
|||||||
destroyMpvSocket: () => {},
|
destroyMpvSocket: () => {},
|
||||||
clearReconnectTimer: () => {},
|
clearReconnectTimer: () => {},
|
||||||
destroySubtitleTimingTracker: () => {},
|
destroySubtitleTimingTracker: () => {},
|
||||||
|
stopStatsServer: () => {},
|
||||||
destroyImmersionTracker: () => {},
|
destroyImmersionTracker: () => {},
|
||||||
destroyAnkiIntegration: () => {},
|
destroyAnkiIntegration: () => {},
|
||||||
destroyAnilistSetupWindow: () => {},
|
destroyAnilistSetupWindow: () => {},
|
||||||
@@ -109,7 +166,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
|||||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.throws(() => cleanup(), /stop failed/);
|
await assert.rejects(cleanup(), /stop failed/);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'stop-jellyfin-remote',
|
'stop-jellyfin-remote',
|
||||||
'cleanup-jellyfin-subtitles',
|
'cleanup-jellyfin-subtitles',
|
||||||
|
|||||||
@@ -1,3 +1,26 @@
|
|||||||
|
export function createForceQuitHandler(deps: {
|
||||||
|
destroyImmersionTracker: () => void | Promise<void>;
|
||||||
|
logError: (error: unknown) => void;
|
||||||
|
exit: () => void;
|
||||||
|
}) {
|
||||||
|
return async () => {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
Promise.resolve().then(() => deps.destroyImmersionTracker()),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timeout = setTimeout(() => reject(new Error('Stats finalization timed out.')), 1_000);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logError(error);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
deps.exit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createOnWillQuitCleanupHandler(deps: {
|
export function createOnWillQuitCleanupHandler(deps: {
|
||||||
destroyTray: () => void;
|
destroyTray: () => void;
|
||||||
stopConfigHotReload: () => void;
|
stopConfigHotReload: () => void;
|
||||||
@@ -18,7 +41,8 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
destroyMpvSocket: () => void;
|
destroyMpvSocket: () => void;
|
||||||
clearReconnectTimer: () => void;
|
clearReconnectTimer: () => void;
|
||||||
destroySubtitleTimingTracker: () => void;
|
destroySubtitleTimingTracker: () => void;
|
||||||
destroyImmersionTracker: () => void;
|
stopStatsServer: () => Promise<void> | void;
|
||||||
|
destroyImmersionTracker: () => void | Promise<void>;
|
||||||
destroyAnkiIntegration: () => void;
|
destroyAnkiIntegration: () => void;
|
||||||
destroyAnilistSetupWindow: () => void;
|
destroyAnilistSetupWindow: () => void;
|
||||||
clearAnilistSetupWindow: () => void;
|
clearAnilistSetupWindow: () => void;
|
||||||
@@ -36,7 +60,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
cleanupJellyfinSubtitleCache: () => void;
|
cleanupJellyfinSubtitleCache: () => void;
|
||||||
stopDiscordPresenceService: () => void;
|
stopDiscordPresenceService: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (): Promise<void> => {
|
return async (): Promise<void> => {
|
||||||
deps.destroyTray();
|
deps.destroyTray();
|
||||||
deps.stopConfigHotReload();
|
deps.stopConfigHotReload();
|
||||||
deps.restorePreviousSecondarySubVisibility();
|
deps.restorePreviousSecondarySubVisibility();
|
||||||
@@ -44,7 +68,12 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
deps.unregisterAllGlobalShortcuts();
|
deps.unregisterAllGlobalShortcuts();
|
||||||
deps.stopSubtitleWebsocket();
|
deps.stopSubtitleWebsocket();
|
||||||
deps.stopTexthookerService();
|
deps.stopTexthookerService();
|
||||||
const stopSyncAutoScheduler = deps.stopSyncAutoScheduler();
|
const cleanupErrors: unknown[] = [];
|
||||||
|
const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch(
|
||||||
|
(error: unknown) => {
|
||||||
|
cleanupErrors.push(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
deps.clearWindowsVisibleOverlayForegroundPollLoop();
|
deps.clearWindowsVisibleOverlayForegroundPollLoop();
|
||||||
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||||
deps.destroyMainOverlayWindow();
|
deps.destroyMainOverlayWindow();
|
||||||
@@ -56,7 +85,16 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
deps.destroyMpvSocket();
|
deps.destroyMpvSocket();
|
||||||
deps.clearReconnectTimer();
|
deps.clearReconnectTimer();
|
||||||
deps.destroySubtitleTimingTracker();
|
deps.destroySubtitleTimingTracker();
|
||||||
deps.destroyImmersionTracker();
|
try {
|
||||||
|
await deps.stopStatsServer();
|
||||||
|
} catch (error) {
|
||||||
|
cleanupErrors.push(error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deps.destroyImmersionTracker();
|
||||||
|
} catch (error) {
|
||||||
|
cleanupErrors.push(error);
|
||||||
|
}
|
||||||
deps.destroyAnkiIntegration();
|
deps.destroyAnkiIntegration();
|
||||||
deps.destroyAnilistSetupWindow();
|
deps.destroyAnilistSetupWindow();
|
||||||
deps.clearAnilistSetupWindow();
|
deps.clearAnilistSetupWindow();
|
||||||
@@ -79,7 +117,10 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
deps.cleanupYoutubeMediaCache();
|
deps.cleanupYoutubeMediaCache();
|
||||||
deps.cleanupRemoteMediaWindows();
|
deps.cleanupRemoteMediaWindows();
|
||||||
deps.stopDiscordPresenceService();
|
deps.stopDiscordPresenceService();
|
||||||
return Promise.resolve(stopSyncAutoScheduler);
|
await stopSyncAutoScheduler;
|
||||||
|
if (cleanupErrors.length > 0) {
|
||||||
|
throw cleanupErrors[0];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import test from 'node:test';
|
|||||||
import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup';
|
import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup';
|
||||||
import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions';
|
import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions';
|
||||||
|
|
||||||
test('cleanup deps builder returns handlers that guard optional runtime objects', () => {
|
test('cleanup deps builder returns handlers that guard optional runtime objects', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 60_000);
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 60_000);
|
||||||
let immersionTracker: { destroy: () => void } | null = {
|
let immersionTracker: { destroy: () => Promise<void> } | null = {
|
||||||
destroy: () => calls.push('destroy-immersion'),
|
destroy: async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
calls.push('destroy-immersion');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
|
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
|
||||||
@@ -54,6 +57,9 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
|
|
||||||
getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }),
|
getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }),
|
||||||
getImmersionTracker: () => immersionTracker,
|
getImmersionTracker: () => immersionTracker,
|
||||||
|
stopStatsServer: () => {
|
||||||
|
calls.push('stop-stats-server');
|
||||||
|
},
|
||||||
clearImmersionTracker: () => {
|
clearImmersionTracker: () => {
|
||||||
immersionTracker = null;
|
immersionTracker = null;
|
||||||
calls.push('clear-immersion-ref');
|
calls.push('clear-immersion-ref');
|
||||||
@@ -81,7 +87,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
|
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
|
||||||
cleanup();
|
await cleanup();
|
||||||
|
|
||||||
assert.ok(calls.includes('destroy-tray'));
|
assert.ok(calls.includes('destroy-tray'));
|
||||||
assert.ok(calls.includes('destroy-main-overlay-window'));
|
assert.ok(calls.includes('destroy-main-overlay-window'));
|
||||||
@@ -94,6 +100,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
assert.ok(calls.includes('clear-reconnect-ref'));
|
assert.ok(calls.includes('clear-reconnect-ref'));
|
||||||
assert.ok(calls.includes('destroy-immersion'));
|
assert.ok(calls.includes('destroy-immersion'));
|
||||||
assert.ok(calls.includes('clear-immersion-ref'));
|
assert.ok(calls.includes('clear-immersion-ref'));
|
||||||
|
assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('clear-immersion-ref'));
|
||||||
assert.ok(calls.includes('destroy-first-run-window'));
|
assert.ok(calls.includes('destroy-first-run-window'));
|
||||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||||
@@ -144,6 +151,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
|||||||
clearReconnectTimerRef: () => {},
|
clearReconnectTimerRef: () => {},
|
||||||
getSubtitleTimingTracker: () => null,
|
getSubtitleTimingTracker: () => null,
|
||||||
getImmersionTracker: () => null,
|
getImmersionTracker: () => null,
|
||||||
|
stopStatsServer: () => {},
|
||||||
clearImmersionTracker: () => {},
|
clearImmersionTracker: () => {},
|
||||||
getAnkiIntegration: () => null,
|
getAnkiIntegration: () => null,
|
||||||
getAnilistSetupWindow: () => null,
|
getAnilistSetupWindow: () => null,
|
||||||
@@ -198,6 +206,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
|||||||
clearReconnectTimerRef: () => {},
|
clearReconnectTimerRef: () => {},
|
||||||
getSubtitleTimingTracker: () => null,
|
getSubtitleTimingTracker: () => null,
|
||||||
getImmersionTracker: () => null,
|
getImmersionTracker: () => null,
|
||||||
|
stopStatsServer: () => {},
|
||||||
clearImmersionTracker: () => {},
|
clearImmersionTracker: () => {},
|
||||||
getAnkiIntegration: () => null,
|
getAnkiIntegration: () => null,
|
||||||
getAnilistSetupWindow: () => null,
|
getAnilistSetupWindow: () => null,
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
clearReconnectTimerRef: () => void;
|
clearReconnectTimerRef: () => void;
|
||||||
|
|
||||||
getSubtitleTimingTracker: () => Destroyable | null;
|
getSubtitleTimingTracker: () => Destroyable | null;
|
||||||
getImmersionTracker: () => Destroyable | null;
|
getImmersionTracker: () => { destroy: () => void | Promise<void> } | null;
|
||||||
|
stopStatsServer: () => Promise<void> | void;
|
||||||
clearImmersionTracker: () => void;
|
clearImmersionTracker: () => void;
|
||||||
getAnkiIntegration: () => Destroyable | null;
|
getAnkiIntegration: () => Destroyable | null;
|
||||||
|
|
||||||
@@ -120,10 +121,11 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
destroySubtitleTimingTracker: () => {
|
destroySubtitleTimingTracker: () => {
|
||||||
deps.getSubtitleTimingTracker()?.destroy();
|
deps.getSubtitleTimingTracker()?.destroy();
|
||||||
},
|
},
|
||||||
destroyImmersionTracker: () => {
|
stopStatsServer: () => deps.stopStatsServer(),
|
||||||
|
destroyImmersionTracker: async () => {
|
||||||
const tracker = deps.getImmersionTracker();
|
const tracker = deps.getImmersionTracker();
|
||||||
if (!tracker) return;
|
if (!tracker) return;
|
||||||
tracker.destroy();
|
await tracker.destroy();
|
||||||
deps.clearImmersionTracker();
|
deps.clearImmersionTracker();
|
||||||
},
|
},
|
||||||
destroyAnkiIntegration: () => {
|
destroyAnkiIntegration: () => {
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ function createDeps(
|
|||||||
return { deps, calls };
|
return { deps, calls };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('ensures background stats server and logs local startup', () => {
|
test('ensures background stats server and logs local startup', async () => {
|
||||||
const { deps, calls } = createDeps();
|
const { deps, calls } = createDeps();
|
||||||
|
|
||||||
createEnsureBackgroundStatsServerHandler(deps)();
|
await createEnsureBackgroundStatsServerHandler(deps)();
|
||||||
|
|
||||||
assert.ok(calls.includes('ensureBackgroundStatsServerStarted'));
|
assert.ok(calls.includes('ensureBackgroundStatsServerStarted'));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -35,7 +35,7 @@ test('ensures background stats server and logs local startup', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logs reuse when a background stats server is already running', () => {
|
test('logs reuse when a background stats server is already running', async () => {
|
||||||
const { deps, calls } = createDeps({
|
const { deps, calls } = createDeps({
|
||||||
ensureBackgroundStatsServerStarted: () => ({
|
ensureBackgroundStatsServerStarted: () => ({
|
||||||
url: 'http://127.0.0.1:3888',
|
url: 'http://127.0.0.1:3888',
|
||||||
@@ -43,36 +43,53 @@ test('logs reuse when a background stats server is already running', () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
createEnsureBackgroundStatsServerHandler(deps)();
|
await createEnsureBackgroundStatsServerHandler(deps)();
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
calls.some((value) => value.startsWith('info:') && /already running|reusing/i.test(value)),
|
calls.some((value) => value.startsWith('info:') && /already running|reusing/i.test(value)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('skips when stats.autoStartServer is disabled', () => {
|
test('skips when stats.autoStartServer is disabled', async () => {
|
||||||
const { deps, calls } = createDeps({ isStatsAutoStartEnabled: () => false });
|
const { deps, calls } = createDeps({ isStatsAutoStartEnabled: () => false });
|
||||||
|
|
||||||
createEnsureBackgroundStatsServerHandler(deps)();
|
await createEnsureBackgroundStatsServerHandler(deps)();
|
||||||
|
|
||||||
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
|
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('skips when immersion tracking is disabled', () => {
|
test('skips when immersion tracking is disabled', async () => {
|
||||||
const { deps, calls } = createDeps({ isImmersionTrackingEnabled: () => false });
|
const { deps, calls } = createDeps({ isImmersionTrackingEnabled: () => false });
|
||||||
|
|
||||||
createEnsureBackgroundStatsServerHandler(deps)();
|
await createEnsureBackgroundStatsServerHandler(deps)();
|
||||||
|
|
||||||
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
|
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logs a warning instead of throwing when startup fails', () => {
|
test('logs a warning instead of throwing when startup fails', async () => {
|
||||||
const { deps, calls } = createDeps({
|
const { deps, calls } = createDeps({
|
||||||
ensureBackgroundStatsServerStarted: () => {
|
ensureBackgroundStatsServerStarted: () => {
|
||||||
throw new Error('port in use');
|
throw new Error('port in use');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.doesNotThrow(() => createEnsureBackgroundStatsServerHandler(deps)());
|
await assert.doesNotReject(createEnsureBackgroundStatsServerHandler(deps)());
|
||||||
assert.ok(calls.some((value) => value.startsWith('warn:')));
|
assert.ok(calls.some((value) => value.startsWith('warn:')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('logs an asynchronously reported startup failure', async () => {
|
||||||
|
const { deps, calls } = createDeps({
|
||||||
|
ensureBackgroundStatsServerStarted: async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
throw new Error('address in use');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createEnsureBackgroundStatsServerHandler(deps)();
|
||||||
|
|
||||||
|
assert.ok(calls.some((value) => value.startsWith('warn:')));
|
||||||
|
assert.equal(
|
||||||
|
calls.some((value) => value.startsWith('info:')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
export interface EnsureBackgroundStatsServerDeps {
|
export interface EnsureBackgroundStatsServerDeps {
|
||||||
isStatsAutoStartEnabled: () => boolean;
|
isStatsAutoStartEnabled: () => boolean;
|
||||||
isImmersionTrackingEnabled: () => boolean;
|
isImmersionTrackingEnabled: () => boolean;
|
||||||
ensureBackgroundStatsServerStarted: () => {
|
ensureBackgroundStatsServerStarted: () =>
|
||||||
|
| Promise<{
|
||||||
|
url: string;
|
||||||
|
runningInCurrentProcess: boolean;
|
||||||
|
}>
|
||||||
|
| {
|
||||||
url: string;
|
url: string;
|
||||||
runningInCurrentProcess: boolean;
|
runningInCurrentProcess: boolean;
|
||||||
};
|
};
|
||||||
@@ -11,8 +16,8 @@ export interface EnsureBackgroundStatsServerDeps {
|
|||||||
|
|
||||||
export function createEnsureBackgroundStatsServerHandler(
|
export function createEnsureBackgroundStatsServerHandler(
|
||||||
deps: EnsureBackgroundStatsServerDeps,
|
deps: EnsureBackgroundStatsServerDeps,
|
||||||
): () => void {
|
): () => Promise<void> {
|
||||||
return () => {
|
return async () => {
|
||||||
if (!deps.isStatsAutoStartEnabled()) {
|
if (!deps.isStatsAutoStartEnabled()) {
|
||||||
deps.logInfo('Background start: stats.autoStartServer is disabled; skipping stats server.');
|
deps.logInfo('Background start: stats.autoStartServer is disabled; skipping stats server.');
|
||||||
return;
|
return;
|
||||||
@@ -22,7 +27,7 @@ export function createEnsureBackgroundStatsServerHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = deps.ensureBackgroundStatsServerStarted();
|
const result = await deps.ensureBackgroundStatsServerStarted();
|
||||||
deps.logInfo(
|
deps.logInfo(
|
||||||
result.runningInCurrentProcess
|
result.runningInCurrentProcess
|
||||||
? `Background start: stats server started at ${result.url}.`
|
? `Background start: stats server started at ${result.url}.`
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
|||||||
clearReconnectTimerRef: () => {},
|
clearReconnectTimerRef: () => {},
|
||||||
getSubtitleTimingTracker: () => null,
|
getSubtitleTimingTracker: () => null,
|
||||||
getImmersionTracker: () => null,
|
getImmersionTracker: () => null,
|
||||||
|
stopStatsServer: () => {},
|
||||||
clearImmersionTracker: () => {},
|
clearImmersionTracker: () => {},
|
||||||
getAnkiIntegration: () => null,
|
getAnkiIntegration: () => null,
|
||||||
getAnilistSetupWindow: () => null,
|
getAnilistSetupWindow: () => null,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export type StartupLifecycleComposerOptions = ComposerInputs<{
|
|||||||
|
|
||||||
export type StartupLifecycleComposerResult = ComposerOutputs<{
|
export type StartupLifecycleComposerResult = ComposerOutputs<{
|
||||||
registerProtocolUrlHandlers: () => void;
|
registerProtocolUrlHandlers: () => void;
|
||||||
onWillQuitCleanup: () => void;
|
onWillQuitCleanup: () => Promise<void>;
|
||||||
shouldRestoreWindowsOnActivate: () => boolean;
|
shouldRestoreWindowsOnActivate: () => boolean;
|
||||||
restoreWindowsOnActivate: () => void;
|
restoreWindowsOnActivate: () => void;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ export function createRunStatsCliCommandHandler(deps: {
|
|||||||
}) => Promise<DuplicateSubtitleLineCleanupSummary>;
|
}) => Promise<DuplicateSubtitleLineCleanupSummary>;
|
||||||
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
|
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
|
||||||
} | null;
|
} | null;
|
||||||
ensureStatsServerStarted: () => string;
|
ensureStatsServerStarted: () => Promise<string> | string;
|
||||||
ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult;
|
ensureBackgroundStatsServerStarted: () =>
|
||||||
|
| Promise<BackgroundStatsStartResult>
|
||||||
|
| BackgroundStatsStartResult;
|
||||||
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
|
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
|
||||||
openExternal: (url: string) => Promise<unknown>;
|
openExternal: (url: string) => Promise<unknown>;
|
||||||
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
|
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
|
||||||
@@ -115,7 +117,7 @@ export function createRunStatsCliCommandHandler(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.statsBackground) {
|
if (args.statsBackground) {
|
||||||
const result = deps.ensureBackgroundStatsServerStarted();
|
const result = await deps.ensureBackgroundStatsServerStarted();
|
||||||
deps.logInfo(`Stats dashboard available at ${result.url}`);
|
deps.logInfo(`Stats dashboard available at ${result.url}`);
|
||||||
writeResponseSafe(args.statsResponsePath, { ok: true, url: result.url });
|
writeResponseSafe(args.statsResponsePath, { ok: true, url: result.url });
|
||||||
if (!result.runningInCurrentProcess && source === 'initial') {
|
if (!result.runningInCurrentProcess && source === 'initial') {
|
||||||
@@ -183,7 +185,7 @@ export function createRunStatsCliCommandHandler(deps: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = deps.ensureStatsServerStarted();
|
const url = await deps.ensureStatsServerStarted();
|
||||||
if (config.stats.autoOpenBrowser !== false) {
|
if (config.stats.autoOpenBrowser !== false) {
|
||||||
await deps.openExternal(url);
|
await deps.openExternal(url);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function createHarness(options?: {
|
|||||||
return options?.processAlive ?? true;
|
return options?.processAlive ?? true;
|
||||||
},
|
},
|
||||||
hasLocalStatsServer: () => localServerStarted,
|
hasLocalStatsServer: () => localServerStarted,
|
||||||
startLocalStatsServer: () => {
|
startLocalStatsServer: async () => {
|
||||||
calls.push('startLocalStatsServer');
|
calls.push('startLocalStatsServer');
|
||||||
localServerStarted = true;
|
localServerStarted = true;
|
||||||
},
|
},
|
||||||
@@ -36,23 +36,23 @@ function createHarness(options?: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('stats server routing defers to a live background daemon from another process', () => {
|
test('stats server routing defers to a live background daemon from another process', async () => {
|
||||||
const { calls, handler } = createHarness({
|
const { calls, handler } = createHarness({
|
||||||
state: { pid: 200, port: 7979, startedAtMs: 1 },
|
state: { pid: 200, port: 7979, startedAtMs: 1 },
|
||||||
processAlive: true,
|
processAlive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(handler(), { url: 'http://127.0.0.1:7979', source: 'background' });
|
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:7979', source: 'background' });
|
||||||
assert.deepEqual(calls, ['readBackgroundState', 'isProcessAlive']);
|
assert.deepEqual(calls, ['readBackgroundState', 'isProcessAlive']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats server routing clears dead daemon state and starts local server', () => {
|
test('stats server routing clears dead daemon state and starts local server', async () => {
|
||||||
const { calls, handler } = createHarness({
|
const { calls, handler } = createHarness({
|
||||||
state: { pid: 200, port: 7979, startedAtMs: 1 },
|
state: { pid: 200, port: 7979, startedAtMs: 1 },
|
||||||
processAlive: false,
|
processAlive: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
|
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'readBackgroundState',
|
'readBackgroundState',
|
||||||
'isProcessAlive',
|
'isProcessAlive',
|
||||||
@@ -61,13 +61,13 @@ test('stats server routing clears dead daemon state and starts local server', ()
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats server routing clears self-owned stale state and starts local server', () => {
|
test('stats server routing clears self-owned stale state and starts local server', async () => {
|
||||||
const { calls, handler } = createHarness({
|
const { calls, handler } = createHarness({
|
||||||
state: { pid: 100, port: 7979, startedAtMs: 1 },
|
state: { pid: 100, port: 7979, startedAtMs: 1 },
|
||||||
processAlive: true,
|
processAlive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
|
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'readBackgroundState',
|
'readBackgroundState',
|
||||||
'removeBackgroundState',
|
'removeBackgroundState',
|
||||||
@@ -75,12 +75,12 @@ test('stats server routing clears self-owned stale state and starts local server
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats server routing reuses a started local stats server', () => {
|
test('stats server routing reuses a started local stats server', async () => {
|
||||||
const { calls, handler } = createHarness({
|
const { calls, handler } = createHarness({
|
||||||
state: null,
|
state: null,
|
||||||
localServerStarted: true,
|
localServerStarted: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
|
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
|
||||||
assert.deepEqual(calls, ['readBackgroundState', 'removeBackgroundState']);
|
assert.deepEqual(calls, ['readBackgroundState', 'removeBackgroundState']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type EnsureStatsServerUrlDeps = {
|
|||||||
removeBackgroundState: () => void;
|
removeBackgroundState: () => void;
|
||||||
isProcessAlive: (pid: number) => boolean;
|
isProcessAlive: (pid: number) => boolean;
|
||||||
hasLocalStatsServer: () => boolean;
|
hasLocalStatsServer: () => boolean;
|
||||||
startLocalStatsServer: () => void;
|
startLocalStatsServer: () => Promise<void>;
|
||||||
getConfiguredPort: () => number;
|
getConfiguredPort: () => number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ export type EnsureStatsServerUrlResult = { url: string; source: 'background' | '
|
|||||||
|
|
||||||
export function createEnsureStatsServerUrlHandler(
|
export function createEnsureStatsServerUrlHandler(
|
||||||
deps: EnsureStatsServerUrlDeps,
|
deps: EnsureStatsServerUrlDeps,
|
||||||
): () => EnsureStatsServerUrlResult {
|
): () => Promise<EnsureStatsServerUrlResult> {
|
||||||
return () => {
|
return async () => {
|
||||||
const state = deps.readBackgroundState();
|
const state = deps.readBackgroundState();
|
||||||
if (!state) {
|
if (!state) {
|
||||||
deps.removeBackgroundState();
|
deps.removeBackgroundState();
|
||||||
@@ -32,7 +32,7 @@ export function createEnsureStatsServerUrlHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!deps.hasLocalStatsServer()) {
|
if (!deps.hasLocalStatsServer()) {
|
||||||
deps.startLocalStatsServer();
|
await deps.startLocalStatsServer();
|
||||||
}
|
}
|
||||||
return { url: formatStatsServerUrl(deps.getConfiguredPort()), source: 'local' };
|
return { url: formatStatsServerUrl(deps.getConfiguredPort()), source: 'local' };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,77 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test, { after } from 'node:test';
|
||||||
|
import { DEFAULT_CONFIG } from '../../config';
|
||||||
|
import { ImmersionTrackerService } from '../../core/services/immersion-tracker-service';
|
||||||
|
import { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
|
||||||
import {
|
import {
|
||||||
createStatsServerRuntime,
|
createStatsServerRuntime,
|
||||||
isSelfOwnedBackgroundStatsDaemonState,
|
isSelfOwnedBackgroundStatsDaemonState,
|
||||||
shouldClearAppStateStatsServerOnStop,
|
type StatsServerRuntimeDeps,
|
||||||
} from './stats-server-runtime';
|
} from './stats-server-runtime';
|
||||||
|
import type { StatsServer } from '../../core/services/stats-server';
|
||||||
|
import type { BackgroundStatsServerState } from './stats-daemon';
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let settle: ((value: T) => void) | null = null;
|
||||||
|
let fail: ((error: unknown) => void) | null = null;
|
||||||
|
const promise = new Promise<T>((resolve, reject) => {
|
||||||
|
settle = resolve;
|
||||||
|
fail = reject;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
promise,
|
||||||
|
resolve(value: T): void {
|
||||||
|
if (!settle) throw new Error('deferred promise is unavailable');
|
||||||
|
settle(value);
|
||||||
|
},
|
||||||
|
reject(error: unknown): void {
|
||||||
|
if (!fail) throw new Error('deferred promise is unavailable');
|
||||||
|
fail(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntimeHarness(
|
||||||
|
startServer: NonNullable<StatsServerRuntimeDeps['startServer']>,
|
||||||
|
backgroundState: BackgroundStatsServerState | null = null,
|
||||||
|
) {
|
||||||
|
const appStateValues: Array<StatsServer | null> = [];
|
||||||
|
const tracker = new ImmersionTrackerService({ dbPath: ':memory:' });
|
||||||
|
after(() => tracker.destroy());
|
||||||
|
const runtime = createStatsServerRuntime({
|
||||||
|
userDataPath: '/tmp/subminer-stats-runtime-test',
|
||||||
|
statsDistPath: '/tmp/stats-dist',
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
stats: { ...DEFAULT_CONFIG.stats, serverPort: 5175 },
|
||||||
|
}),
|
||||||
|
getImmersionTracker: () => tracker,
|
||||||
|
setAppStateStatsServer: (server) => {
|
||||||
|
appStateValues.push(server);
|
||||||
|
},
|
||||||
|
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||||
|
getYomitanExt: () => null,
|
||||||
|
getYomitanSession: () => null,
|
||||||
|
getYomitanParserWindow: () => null,
|
||||||
|
setYomitanParserWindow: () => {},
|
||||||
|
getYomitanParserReadyPromise: () => null,
|
||||||
|
setYomitanParserReadyPromise: () => {},
|
||||||
|
getYomitanParserInitPromise: () => null,
|
||||||
|
setYomitanParserInitPromise: () => {},
|
||||||
|
getYomitanAnkiDeckName: async () => 'Mining',
|
||||||
|
getAnilistRateLimiter: () => createAnilistRateLimiter(),
|
||||||
|
resolveAnkiNoteId: (noteId) => noteId,
|
||||||
|
trackDuplicateNoteIdsForNote: () => {},
|
||||||
|
resolveSentenceSearchHeadwords: async () => [],
|
||||||
|
ensureImmersionTrackerStarted: () => {},
|
||||||
|
setStatsStartupInProgress: () => {},
|
||||||
|
readBackgroundStatsServerState: () => backgroundState,
|
||||||
|
removeBackgroundStatsServerState: () => {},
|
||||||
|
isBackgroundStatsServerProcessAlive: () => false,
|
||||||
|
startServer,
|
||||||
|
});
|
||||||
|
return { runtime, appStateValues };
|
||||||
|
}
|
||||||
|
|
||||||
test('detects self-owned background stats daemon state', () => {
|
test('detects self-owned background stats daemon state', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -13,10 +80,6 @@ test('detects self-owned background stats daemon state', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats server app-state reference should be cleared after private server stop', () => {
|
|
||||||
assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
|
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const runtime = createStatsServerRuntime({
|
const runtime = createStatsServerRuntime({
|
||||||
@@ -57,3 +120,157 @@ test('stopBackgroundStatsServer clears stale state when daemon identity mismatch
|
|||||||
assert.deepEqual(result, { ok: true, stale: true });
|
assert.deepEqual(result, { ok: true, stale: true });
|
||||||
assert.deepEqual(calls, ['removeBackgroundStatsServerState']);
|
assert.deepEqual(calls, ['removeBackgroundStatsServerState']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('concurrent stats startup requests share one pending server', async () => {
|
||||||
|
const deferred = createDeferred<StatsServer>();
|
||||||
|
let startCalls = 0;
|
||||||
|
const server: StatsServer = { close: async () => {} };
|
||||||
|
const { runtime, appStateValues } = createRuntimeHarness(() => {
|
||||||
|
startCalls += 1;
|
||||||
|
return deferred.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = runtime.ensureStatsServerStarted();
|
||||||
|
const second = runtime.ensureStatsServerStarted();
|
||||||
|
assert.equal(startCalls, 1);
|
||||||
|
assert.deepEqual(appStateValues, []);
|
||||||
|
|
||||||
|
deferred.resolve(server);
|
||||||
|
assert.deepEqual(await Promise.all([first, second]), [
|
||||||
|
{ url: 'http://127.0.0.1:5175', source: 'local' },
|
||||||
|
{ url: 'http://127.0.0.1:5175', source: 'local' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(appStateValues, [server]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed stats startup remains recoverable on the next request', async () => {
|
||||||
|
const first = createDeferred<StatsServer>();
|
||||||
|
const second = createDeferred<StatsServer>();
|
||||||
|
const attempts = [first, second];
|
||||||
|
let startCalls = 0;
|
||||||
|
const server: StatsServer = { close: async () => {} };
|
||||||
|
const { runtime, appStateValues } = createRuntimeHarness(() => {
|
||||||
|
const attempt = attempts[startCalls];
|
||||||
|
startCalls += 1;
|
||||||
|
if (!attempt) throw new Error('unexpected startup attempt');
|
||||||
|
return attempt.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
const failedStartup = runtime.ensureStatsServerStarted();
|
||||||
|
first.reject(Object.assign(new Error('address in use'), { code: 'EADDRINUSE' }));
|
||||||
|
await assert.rejects(failedStartup, /address in use/);
|
||||||
|
|
||||||
|
const retry = runtime.ensureStatsServerStarted();
|
||||||
|
second.resolve(server);
|
||||||
|
assert.deepEqual(await retry, { url: 'http://127.0.0.1:5175', source: 'local' });
|
||||||
|
assert.equal(startCalls, 2);
|
||||||
|
assert.deepEqual(appStateValues, [null, server]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shutdown cancels pending startup and closes the late server', async () => {
|
||||||
|
const deferred = createDeferred<StatsServer>();
|
||||||
|
let closeCalls = 0;
|
||||||
|
const server: StatsServer = {
|
||||||
|
close: async () => {
|
||||||
|
closeCalls += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { runtime, appStateValues } = createRuntimeHarness(() => deferred.promise);
|
||||||
|
|
||||||
|
const startup = runtime.ensureStatsServerStarted();
|
||||||
|
const shutdown = runtime.stopStatsServer();
|
||||||
|
deferred.resolve(server);
|
||||||
|
|
||||||
|
await assert.rejects(startup, /startup was cancelled/);
|
||||||
|
await shutdown;
|
||||||
|
assert.equal(closeCalls, 1);
|
||||||
|
assert.deepEqual(appStateValues, [null, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stopping a self-owned background server closes its local handle', async () => {
|
||||||
|
let closeCalls = 0;
|
||||||
|
const server: StatsServer = {
|
||||||
|
close: async () => {
|
||||||
|
closeCalls += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { runtime } = createRuntimeHarness(async () => server, {
|
||||||
|
pid: process.pid,
|
||||||
|
port: 5175,
|
||||||
|
startedAtMs: 1,
|
||||||
|
});
|
||||||
|
await runtime.ensureStatsServerStarted();
|
||||||
|
|
||||||
|
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: false });
|
||||||
|
assert.equal(closeCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('background stop leaves a foreground-only server available', async () => {
|
||||||
|
let closeCalls = 0;
|
||||||
|
let startCalls = 0;
|
||||||
|
const { runtime } = createRuntimeHarness(async () => {
|
||||||
|
startCalls += 1;
|
||||||
|
return {
|
||||||
|
close: async () => {
|
||||||
|
closeCalls += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const foreground = await runtime.ensureStatsServerStarted();
|
||||||
|
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: true });
|
||||||
|
assert.equal(closeCalls, 0);
|
||||||
|
assert.deepEqual(await runtime.ensureStatsServerStarted(), foreground);
|
||||||
|
assert.equal(startCalls, 1);
|
||||||
|
await runtime.stopStatsServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('background stop leaves a pending foreground-only startup alone', async () => {
|
||||||
|
const deferred = createDeferred<StatsServer>();
|
||||||
|
const { runtime } = createRuntimeHarness(() => deferred.promise);
|
||||||
|
const startup = runtime.ensureStatsServerStarted();
|
||||||
|
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: true });
|
||||||
|
deferred.resolve({ close: async () => {} });
|
||||||
|
assert.deepEqual(await startup, { url: 'http://127.0.0.1:5175', source: 'local' });
|
||||||
|
await runtime.stopStatsServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a startup requested during shutdown waits and then restarts', async () => {
|
||||||
|
const closeDeferred = createDeferred<void>();
|
||||||
|
const firstServer: StatsServer = { close: () => closeDeferred.promise };
|
||||||
|
const secondServer: StatsServer = { close: async () => {} };
|
||||||
|
const servers = [firstServer, secondServer];
|
||||||
|
let startCalls = 0;
|
||||||
|
const { runtime } = createRuntimeHarness(async () => {
|
||||||
|
const server = servers[startCalls];
|
||||||
|
startCalls += 1;
|
||||||
|
if (!server) throw new Error('unexpected startup attempt');
|
||||||
|
return server;
|
||||||
|
});
|
||||||
|
await runtime.ensureStatsServerStarted();
|
||||||
|
|
||||||
|
const shutdown = runtime.stopStatsServer();
|
||||||
|
const restart = runtime.ensureStatsServerStarted();
|
||||||
|
assert.equal(startCalls, 1);
|
||||||
|
|
||||||
|
closeDeferred.resolve();
|
||||||
|
await shutdown;
|
||||||
|
assert.deepEqual(await restart, { url: 'http://127.0.0.1:5175', source: 'local' });
|
||||||
|
assert.equal(startCalls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('background stop cancels startup before daemon ownership is published', async () => {
|
||||||
|
const deferred = createDeferred<StatsServer>();
|
||||||
|
let closeCalls = 0;
|
||||||
|
const { runtime, appStateValues } = createRuntimeHarness(() => deferred.promise);
|
||||||
|
const startup = runtime.ensureBackgroundStatsServerStarted();
|
||||||
|
const shutdown = runtime.stopBackgroundStatsServer();
|
||||||
|
deferred.resolve({
|
||||||
|
close: async () => {
|
||||||
|
closeCalls += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(startup, /startup was cancelled/);
|
||||||
|
assert.deepEqual(await shutdown, { ok: true, stale: false });
|
||||||
|
assert.equal(closeCalls, 1);
|
||||||
|
assert.equal(appStateValues.at(-1), null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
addYomitanNoteViaSearch,
|
addYomitanNoteViaSearch,
|
||||||
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
||||||
} from '../../core/services';
|
} from '../../core/services';
|
||||||
import { startStatsServer } from '../../core/services/stats-server';
|
import { startStatsServer, type StatsServer } from '../../core/services/stats-server';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
import type { ResolvedConfig } from '../../types/config';
|
import type { ResolvedConfig } from '../../types/config';
|
||||||
import type { AppState } from '../state';
|
import type { AppState } from '../state';
|
||||||
@@ -27,12 +27,6 @@ export function isSelfOwnedBackgroundStatsDaemonState(state: {
|
|||||||
return state.pid === process.pid;
|
return state.pid === process.pid;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldClearAppStateStatsServerOnStop(options: {
|
|
||||||
hadStatsServer: boolean;
|
|
||||||
}): boolean {
|
|
||||||
return options.hadStatsServer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsServerRuntimeDeps {
|
export interface StatsServerRuntimeDeps {
|
||||||
userDataPath: string;
|
userDataPath: string;
|
||||||
statsDistPath: string;
|
statsDistPath: string;
|
||||||
@@ -62,19 +56,28 @@ export interface StatsServerRuntimeDeps {
|
|||||||
isBackgroundStatsServerProcessAlive?: typeof defaultIsBackgroundStatsServerProcessAlive;
|
isBackgroundStatsServerProcessAlive?: typeof defaultIsBackgroundStatsServerProcessAlive;
|
||||||
verifyBackgroundStatsServerIdentity?: typeof defaultVerifyBackgroundStatsServerIdentity;
|
verifyBackgroundStatsServerIdentity?: typeof defaultVerifyBackgroundStatsServerIdentity;
|
||||||
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
|
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
|
||||||
|
startServer?: typeof startStatsServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||||
stopStatsServer: () => void;
|
stopStatsServer: () => Promise<void>;
|
||||||
ensureStatsServerStarted: ReturnType<typeof createEnsureStatsServerUrlHandler>;
|
ensureStatsServerStarted: ReturnType<typeof createEnsureStatsServerUrlHandler>;
|
||||||
ensureBackgroundStatsServerStarted: () => {
|
ensureBackgroundStatsServerStarted: () => Promise<{
|
||||||
url: string;
|
url: string;
|
||||||
runningInCurrentProcess: boolean;
|
runningInCurrentProcess: boolean;
|
||||||
};
|
}>;
|
||||||
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
|
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
|
||||||
} {
|
} {
|
||||||
let statsServer: ReturnType<typeof startStatsServer> | null = null;
|
type LocalStatsServerState =
|
||||||
|
| { kind: 'stopped' }
|
||||||
|
| { kind: 'starting'; token: symbol; promise: Promise<void> }
|
||||||
|
| { kind: 'running'; server: StatsServer }
|
||||||
|
| { kind: 'stopping'; token: symbol; promise: Promise<void> };
|
||||||
|
|
||||||
|
let localStatsServerState: LocalStatsServerState = { kind: 'stopped' };
|
||||||
|
const pendingBackgroundStarts = new Set<symbol>();
|
||||||
const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json');
|
const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json');
|
||||||
|
const startServer = deps.startServer ?? startStatsServer;
|
||||||
const readDaemonState =
|
const readDaemonState =
|
||||||
deps.readBackgroundStatsServerState ??
|
deps.readBackgroundStatsServerState ??
|
||||||
((statePath: string) => defaultReadBackgroundStatsServerState(statePath));
|
((statePath: string) => defaultReadBackgroundStatsServerState(statePath));
|
||||||
@@ -100,7 +103,7 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
|||||||
removeDaemonState(statsDaemonStatePath);
|
removeDaemonState(statsDaemonStatePath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (state.pid === process.pid && !statsServer) {
|
if (state.pid === process.pid && localStatsServerState.kind !== 'running') {
|
||||||
removeDaemonState(statsDaemonStatePath);
|
removeDaemonState(statsDaemonStatePath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -118,24 +121,11 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopStatsServer(): void {
|
const buildStatsServerConfig = (): Parameters<typeof startStatsServer>[0] => {
|
||||||
if (!statsServer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
statsServer.close();
|
|
||||||
statsServer = null;
|
|
||||||
if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) {
|
|
||||||
deps.setAppStateStatsServer(null);
|
|
||||||
}
|
|
||||||
clearOwnedBackgroundStatsDaemonState();
|
|
||||||
}
|
|
||||||
|
|
||||||
const startLocalStatsServer = (): void => {
|
|
||||||
const tracker = deps.getImmersionTracker();
|
const tracker = deps.getImmersionTracker();
|
||||||
if (!tracker) {
|
if (!tracker) {
|
||||||
throw new Error('Immersion tracker failed to initialize.');
|
throw new Error('Immersion tracker failed to initialize.');
|
||||||
}
|
}
|
||||||
if (!statsServer) {
|
|
||||||
const yomitanDeps = {
|
const yomitanDeps = {
|
||||||
getYomitanExt: () => deps.getYomitanExt(),
|
getYomitanExt: () => deps.getYomitanExt(),
|
||||||
getYomitanSession: () => deps.getYomitanSession(),
|
getYomitanSession: () => deps.getYomitanSession(),
|
||||||
@@ -153,7 +143,7 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const yomitanLogger = createLogger('main:yomitan-stats');
|
const yomitanLogger = createLogger('main:yomitan-stats');
|
||||||
statsServer = startStatsServer({
|
return {
|
||||||
port: deps.getResolvedConfig().stats.serverPort,
|
port: deps.getResolvedConfig().stats.serverPort,
|
||||||
staticDir: deps.statsDistPath,
|
staticDir: deps.statsDistPath,
|
||||||
tracker,
|
tracker,
|
||||||
@@ -180,11 +170,84 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
|||||||
}
|
}
|
||||||
return result.noteId;
|
return result.noteId;
|
||||||
},
|
},
|
||||||
});
|
|
||||||
deps.setAppStateStatsServer(statsServer);
|
|
||||||
}
|
|
||||||
deps.setAppStateStatsServer(statsServer);
|
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginLocalStatsServerStartup = (): Promise<void> => {
|
||||||
|
const token = Symbol('stats-server-startup');
|
||||||
|
const promise = startServer(buildStatsServerConfig())
|
||||||
|
.then(async (server) => {
|
||||||
|
const state = localStatsServerState;
|
||||||
|
if (state.kind !== 'starting' || state.token !== token) {
|
||||||
|
await server.close();
|
||||||
|
throw new Error('Stats server startup was cancelled.');
|
||||||
|
}
|
||||||
|
localStatsServerState = { kind: 'running', server };
|
||||||
|
deps.setAppStateStatsServer(server);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const state = localStatsServerState;
|
||||||
|
if (state.kind === 'starting' && state.token === token) {
|
||||||
|
localStatsServerState = { kind: 'stopped' };
|
||||||
|
deps.setAppStateStatsServer(null);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
localStatsServerState = { kind: 'starting', token, promise };
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startLocalStatsServer = async (): Promise<void> => {
|
||||||
|
while (localStatsServerState.kind === 'stopping') {
|
||||||
|
await localStatsServerState.promise;
|
||||||
|
}
|
||||||
|
if (localStatsServerState.kind === 'running') {
|
||||||
|
deps.setAppStateStatsServer(localStatsServerState.server);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (localStatsServerState.kind === 'starting') {
|
||||||
|
await localStatsServerState.promise;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await beginLocalStatsServerStartup();
|
||||||
|
};
|
||||||
|
|
||||||
|
function stopStatsServer(): Promise<void> {
|
||||||
|
const state = localStatsServerState;
|
||||||
|
if (state.kind === 'stopped') {
|
||||||
|
deps.setAppStateStatsServer(null);
|
||||||
|
clearOwnedBackgroundStatsDaemonState();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (state.kind === 'stopping') {
|
||||||
|
return state.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = Symbol('stats-server-shutdown');
|
||||||
|
const promise = Promise.resolve()
|
||||||
|
.then(async () => {
|
||||||
|
if (state.kind === 'starting') {
|
||||||
|
try {
|
||||||
|
await state.promise;
|
||||||
|
} catch {
|
||||||
|
// Startup owns cleanup of a server that finishes binding after cancellation.
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await state.server.close();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
const current = localStatsServerState;
|
||||||
|
if (current.kind === 'stopping' && current.token === token) {
|
||||||
|
localStatsServerState = { kind: 'stopped' };
|
||||||
|
}
|
||||||
|
deps.setAppStateStatsServer(null);
|
||||||
|
clearOwnedBackgroundStatsDaemonState();
|
||||||
|
});
|
||||||
|
localStatsServerState = { kind: 'stopping', token, promise };
|
||||||
|
deps.setAppStateStatsServer(null);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
|
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
|
||||||
currentPid: process.pid,
|
currentPid: process.pid,
|
||||||
@@ -193,15 +256,15 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
|||||||
removeDaemonState(statsDaemonStatePath);
|
removeDaemonState(statsDaemonStatePath);
|
||||||
},
|
},
|
||||||
isProcessAlive: (pid) => isDaemonAlive(pid),
|
isProcessAlive: (pid) => isDaemonAlive(pid),
|
||||||
hasLocalStatsServer: () => statsServer !== null,
|
hasLocalStatsServer: () => localStatsServerState.kind === 'running',
|
||||||
startLocalStatsServer,
|
startLocalStatsServer,
|
||||||
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
|
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ensureBackgroundStatsServerStarted = (): {
|
const ensureBackgroundStatsServerStarted = async (): Promise<{
|
||||||
url: string;
|
url: string;
|
||||||
runningInCurrentProcess: boolean;
|
runningInCurrentProcess: boolean;
|
||||||
} => {
|
}> => {
|
||||||
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
||||||
if (liveDaemon && liveDaemon.pid !== process.pid) {
|
if (liveDaemon && liveDaemon.pid !== process.pid) {
|
||||||
return {
|
return {
|
||||||
@@ -217,9 +280,15 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
|||||||
deps.setStatsStartupInProgress(false);
|
deps.setStatsStartupInProgress(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const request = Symbol('background-stats-startup');
|
||||||
|
pendingBackgroundStarts.add(request);
|
||||||
|
try {
|
||||||
const port = deps.getResolvedConfig().stats.serverPort;
|
const port = deps.getResolvedConfig().stats.serverPort;
|
||||||
const result = ensureStatsServerStarted();
|
const result = await ensureStatsServerStarted();
|
||||||
if (result.source === 'local') {
|
if (result.source === 'local') {
|
||||||
|
if (localStatsServerState.kind !== 'running') {
|
||||||
|
throw new Error('Stats server startup was cancelled.');
|
||||||
|
}
|
||||||
writeBackgroundStatsServerState(statsDaemonStatePath, {
|
writeBackgroundStatsServerState(statsDaemonStatePath, {
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
port,
|
port,
|
||||||
@@ -227,17 +296,24 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
|
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
|
||||||
|
} finally {
|
||||||
|
pendingBackgroundStarts.delete(request);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
|
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
|
||||||
const state = readDaemonState(statsDaemonStatePath);
|
const state = readDaemonState(statsDaemonStatePath);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
|
if (pendingBackgroundStarts.size > 0) {
|
||||||
|
await stopStatsServer();
|
||||||
|
return { ok: true, stale: false };
|
||||||
|
}
|
||||||
removeDaemonState(statsDaemonStatePath);
|
removeDaemonState(statsDaemonStatePath);
|
||||||
return { ok: true, stale: true };
|
return { ok: true, stale: true };
|
||||||
}
|
}
|
||||||
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
|
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
|
||||||
removeDaemonState(statsDaemonStatePath);
|
await stopStatsServer();
|
||||||
return { ok: true, stale: true };
|
return { ok: true, stale: false };
|
||||||
}
|
}
|
||||||
if (!isDaemonAlive(state.pid)) {
|
if (!isDaemonAlive(state.pid)) {
|
||||||
removeDaemonState(statsDaemonStatePath);
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
|||||||
+1
-1
@@ -206,7 +206,7 @@ export interface AppState {
|
|||||||
anilistSetupPageOpened: boolean;
|
anilistSetupPageOpened: boolean;
|
||||||
anilistRetryQueueState: AnilistRetryQueueState;
|
anilistRetryQueueState: AnilistRetryQueueState;
|
||||||
firstRunSetupCompleted: boolean;
|
firstRunSetupCompleted: boolean;
|
||||||
statsServer: { close: () => void } | null;
|
statsServer: { close: () => Promise<void> } | null;
|
||||||
statsStartupInProgress: boolean;
|
statsStartupInProgress: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ test('quality gate checkout does not persist GitHub credentials', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('quality gate installs Lua and runs the environment suite before coverage', () => {
|
test('quality gate runs non-covered source suites and lets coverage gate the src lane', () => {
|
||||||
assert.match(qualityGateWorkflow, /name: Install Lua/);
|
assert.match(qualityGateWorkflow, /name: Install Lua/);
|
||||||
assert.match(
|
assert.match(
|
||||||
qualityGateWorkflow,
|
qualityGateWorkflow,
|
||||||
@@ -32,7 +32,18 @@ test('quality gate installs Lua and runs the environment suite before coverage',
|
|||||||
assert.match(qualityGateWorkflow, /apt-get\s+"\$\{apt_sources\[@\]\}"\s+install\s+-y\s+lua5\.4/);
|
assert.match(qualityGateWorkflow, /apt-get\s+"\$\{apt_sources\[@\]\}"\s+install\s+-y\s+lua5\.4/);
|
||||||
assert.match(
|
assert.match(
|
||||||
qualityGateWorkflow,
|
qualityGateWorkflow,
|
||||||
/Test suite \(source\)\n\s*run: bun run test:fast\n\s*\n\s*- name: Environment suite\n\s*run: bun run test:env\n\s*\n\s*- name: Coverage suite \(maintained source lane\)/,
|
/Launcher unit and script suites\n\s*run: bun run test:launcher:unit:src && bun run test:scripts/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(qualityGateWorkflow, /bun run test:fast/);
|
||||||
|
assert.match(qualityGateWorkflow, /run: bun run test:coverage:src/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quality gate runs launcher smoke once through the environment suite and keeps artifacts', () => {
|
||||||
|
assert.match(qualityGateWorkflow, /name: Environment suite\n\s*run: bun run test:env/);
|
||||||
|
assert.doesNotMatch(qualityGateWorkflow, /run: bun run test:launcher:smoke:src/);
|
||||||
|
assert.match(
|
||||||
|
qualityGateWorkflow,
|
||||||
|
/name: Upload launcher smoke artifacts \(on failure\)[\s\S]*?if: failure\(\)[\s\S]*?path: \.tmp\/launcher-smoke\/\*\*/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,6 +53,13 @@ test('quality gate uploads maintained source coverage', () => {
|
|||||||
assert.match(qualityGateWorkflow, /path: coverage\/test-src\/lcov\.info/);
|
assert.match(qualityGateWorkflow, /path: coverage\/test-src\/lcov\.info/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('quality gate preserves stats, compiled SQLite, and dist runtime checks', () => {
|
||||||
|
assert.match(qualityGateWorkflow, /run: bun run test:stats/);
|
||||||
|
assert.match(qualityGateWorkflow, /run: bun run build/);
|
||||||
|
assert.match(qualityGateWorkflow, /run: bun run test:immersion:sqlite:dist/);
|
||||||
|
assert.match(qualityGateWorkflow, /run: bun run test:smoke:dist/);
|
||||||
|
});
|
||||||
|
|
||||||
test('quality gate keeps pull request changelog enforcement event-aware', () => {
|
test('quality gate keeps pull request changelog enforcement event-aware', () => {
|
||||||
assert.match(qualityGateWorkflow, /bun run changelog:lint/);
|
assert.match(qualityGateWorkflow, /bun run changelog:lint/);
|
||||||
assert.match(qualityGateWorkflow, /if: github\.event_name == 'pull_request'/);
|
assert.match(qualityGateWorkflow, /if: github\.event_name == 'pull_request'/);
|
||||||
|
|||||||
@@ -127,7 +127,8 @@ const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
|
|||||||
const wordHelperScriptPath = path.join(__dirname, 'stats-word-helper.js');
|
const wordHelperScriptPath = path.join(__dirname, 'stats-word-helper.js');
|
||||||
|
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
let statsServer: ReturnType<typeof startStatsServer> | null = null;
|
let statsServer: Awaited<ReturnType<typeof startStatsServer>> | null = null;
|
||||||
|
let shutdownPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
function writeFailureResponse(message: string): void {
|
function writeFailureResponse(message: string): void {
|
||||||
if (!responsePath) return;
|
if (!responsePath) return;
|
||||||
@@ -147,25 +148,32 @@ function clearOwnedState(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shutdown(code = 0): void {
|
function shutdown(code = 0): Promise<void> {
|
||||||
|
shutdownPromise ??= (async () => {
|
||||||
try {
|
try {
|
||||||
statsServer?.close();
|
await statsServer?.close();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
statsServer = null;
|
statsServer = null;
|
||||||
try {
|
try {
|
||||||
tracker?.destroy();
|
await tracker?.destroy();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
tracker = null;
|
tracker = null;
|
||||||
clearOwnedState();
|
clearOwnedState();
|
||||||
process.exit(code);
|
process.exit(code);
|
||||||
|
})();
|
||||||
|
return shutdownPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('SIGINT', () => shutdown(0));
|
process.on('SIGINT', () => {
|
||||||
process.on('SIGTERM', () => shutdown(0));
|
void shutdown(0);
|
||||||
|
});
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
void shutdown(0);
|
||||||
|
});
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -198,7 +206,7 @@ async function main(): Promise<void> {
|
|||||||
createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art')),
|
createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art')),
|
||||||
);
|
);
|
||||||
|
|
||||||
statsServer = startStatsServer({
|
statsServer = await startStatsServer({
|
||||||
port: config.stats.serverPort,
|
port: config.stats.serverPort,
|
||||||
staticDir: statsDistPath,
|
staticDir: statsDistPath,
|
||||||
tracker,
|
tracker,
|
||||||
@@ -237,7 +245,7 @@ async function main(): Promise<void> {
|
|||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
logger.error('Failed to start stats daemon', message);
|
logger.error('Failed to start stats daemon', message);
|
||||||
writeFailureResponse(message);
|
writeFailureResponse(message);
|
||||||
shutdown(1);
|
await shutdown(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user