mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
fix(stats): harden server lifecycle and verify compiled runtime (#261)
This commit is contained in:
@@ -5,7 +5,11 @@ import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
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 {
|
||||
clearRetimedSecondarySubtitleCache,
|
||||
@@ -3995,102 +3999,133 @@ Aligned English subtitle
|
||||
assert.equal(ensureCalls, 1);
|
||||
});
|
||||
|
||||
it('starts the stats server with Bun.serve', () => {
|
||||
type BunRuntime = {
|
||||
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;
|
||||
it('starts and stops the stats server with Bun.serve', async () => {
|
||||
const servedOptions: Array<{ fetch: unknown; port: number; hostname: string }> = [];
|
||||
let stopCalls = 0;
|
||||
|
||||
bun.Bun.serve = (options: { fetch: unknown; port: number; hostname: string }) => {
|
||||
servedWith = options;
|
||||
return {
|
||||
stop: () => {
|
||||
stopCalls += 1;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const server = startStatsServer({
|
||||
const server = await startStatsServerWithRuntime(
|
||||
{
|
||||
port: 3210,
|
||||
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-start-')),
|
||||
tracker: createMockTracker(),
|
||||
});
|
||||
},
|
||||
{
|
||||
bunServe: (options) => {
|
||||
servedOptions.push(options);
|
||||
return {
|
||||
stop: () => {
|
||||
stopCalls += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (servedWith === null) {
|
||||
throw new Error('expected Bun.serve to be called');
|
||||
}
|
||||
|
||||
const servedOptions = servedWith as {
|
||||
fetch: unknown;
|
||||
port: number;
|
||||
hostname: string;
|
||||
};
|
||||
assert.equal(servedOptions.port, 3210);
|
||||
assert.equal(servedOptions.hostname, '127.0.0.1');
|
||||
assert.equal(typeof servedOptions.fetch, 'function');
|
||||
|
||||
server.close();
|
||||
assert.equal(stopCalls, 1);
|
||||
} finally {
|
||||
bun.Bun.serve = originalServe;
|
||||
const servedWith = servedOptions[0];
|
||||
if (!servedWith) {
|
||||
throw new Error('expected Bun.serve to be called');
|
||||
}
|
||||
|
||||
assert.equal(servedWith.port, 3210);
|
||||
assert.equal(servedWith.hostname, '127.0.0.1');
|
||||
assert.equal(typeof servedWith.fetch, 'function');
|
||||
|
||||
await Promise.all([server.close(), server.close()]);
|
||||
assert.equal(stopCalls, 1);
|
||||
});
|
||||
|
||||
it('falls back to node:http when Bun.serve is unavailable', () => {
|
||||
type BunRuntime = {
|
||||
Bun: {
|
||||
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;
|
||||
it('waits for node:http listening and converts startup errors into rejections', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const listeningServer = http.createServer();
|
||||
let closeCalls = 0;
|
||||
bun.Bun.serve = undefined;
|
||||
(
|
||||
http as typeof http & {
|
||||
createServer: typeof http.createServer;
|
||||
}
|
||||
).createServer = (() =>
|
||||
({
|
||||
listen: (port: number, hostname: string) => {
|
||||
listenedWith = { port, hostname };
|
||||
},
|
||||
close: () => {
|
||||
Object.defineProperties(listeningServer, {
|
||||
listen: {
|
||||
value: () => listeningServer,
|
||||
},
|
||||
close: {
|
||||
value: (callback?: (error?: Error) => void) => {
|
||||
closeCalls += 1;
|
||||
callback?.();
|
||||
return listeningServer;
|
||||
},
|
||||
}) as unknown as ReturnType<typeof http.createServer>) as typeof http.createServer;
|
||||
},
|
||||
});
|
||||
|
||||
let startupSettled = false;
|
||||
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,
|
||||
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-')),
|
||||
tracker: createMockTracker(),
|
||||
});
|
||||
await Promise.all([server.close(), server.close()]);
|
||||
|
||||
const blocker = http.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
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 {
|
||||
const server = startStatsServer({
|
||||
port: 0,
|
||||
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-')),
|
||||
tracker: createMockTracker(),
|
||||
});
|
||||
|
||||
assert.deepEqual(listenedWith, { port: 0, hostname: '127.0.0.1' });
|
||||
server.close();
|
||||
assert.equal(closeCalls, 1);
|
||||
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 {
|
||||
bun.Bun.serve = originalServe;
|
||||
(
|
||||
http as typeof http & {
|
||||
createServer: typeof http.createServer;
|
||||
}
|
||||
).createServer = originalCreateServer;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -406,6 +406,17 @@ test('handleCliCommand ensures background stats server for second-instance --sta
|
||||
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', () => {
|
||||
const ensured: number[] = [];
|
||||
const { deps } = createDeps({
|
||||
|
||||
@@ -107,7 +107,7 @@ export interface CliCommandServiceDeps {
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
source: CliCommandSource;
|
||||
}) => Promise<void>;
|
||||
ensureBackgroundStatsServer?: () => void;
|
||||
ensureBackgroundStatsServer?: () => Promise<void> | void;
|
||||
printHelp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
@@ -188,7 +188,7 @@ interface AnilistCliRuntime {
|
||||
interface AppCliRuntime {
|
||||
stop: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
ensureBackgroundStatsServer?: () => void;
|
||||
ensureBackgroundStatsServer?: () => Promise<void> | void;
|
||||
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
||||
@@ -400,7 +400,14 @@ export function handleCliCommand(
|
||||
}
|
||||
|
||||
if (args.start && args.background) {
|
||||
deps.ensureBackgroundStatsServer?.();
|
||||
runAsyncWithOsd(
|
||||
async () => {
|
||||
await deps.ensureBackgroundStatsServer?.();
|
||||
},
|
||||
deps,
|
||||
'ensureBackgroundStatsServer',
|
||||
'Stats server startup failed',
|
||||
);
|
||||
}
|
||||
|
||||
if (args.sessionAction) {
|
||||
|
||||
@@ -6,7 +6,9 @@ import { dispatchSessionAction, type SessionActionExecutorDeps } from './session
|
||||
function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
const deps: SessionActionExecutorDeps = {
|
||||
toggleStatsOverlay: () => calls.push('stats'),
|
||||
toggleStatsOverlay: () => {
|
||||
calls.push('stats');
|
||||
},
|
||||
toggleVisibleOverlay: () => calls.push('visible'),
|
||||
copyCurrentSubtitle: () => calls.push('copy'),
|
||||
copySubtitleCount: (count) => calls.push(`copy:${count}`),
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SessionActionId } from '../../types/session-bindings';
|
||||
import type { SessionActionDispatchRequest } from '../../types/runtime';
|
||||
|
||||
export interface SessionActionExecutorDeps {
|
||||
toggleStatsOverlay: () => void;
|
||||
toggleStatsOverlay: () => Promise<void> | void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
copySubtitleCount: (count: number) => void;
|
||||
@@ -50,7 +50,7 @@ export async function dispatchSessionAction(
|
||||
): Promise<void> {
|
||||
switch (request.actionId) {
|
||||
case 'toggleStatsOverlay':
|
||||
deps.toggleStatsOverlay();
|
||||
await deps.toggleStatsOverlay();
|
||||
return;
|
||||
case 'toggleVisibleOverlay':
|
||||
deps.toggleVisibleOverlay();
|
||||
|
||||
@@ -50,8 +50,26 @@ async function writeFetchResponse(res: ServerResponse, response: Response): Prom
|
||||
res.end(Buffer.from(await response.arrayBuffer()));
|
||||
}
|
||||
|
||||
function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } {
|
||||
const server = http.createServer((req, res) => {
|
||||
export interface StatsServer {
|
||||
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 () => {
|
||||
try {
|
||||
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 {
|
||||
close: () => {
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleStartupError = (error: Error): void => {
|
||||
server.removeListener('listening', handleListening);
|
||||
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 {
|
||||
@@ -125,7 +164,10 @@ export function createStatsApp(
|
||||
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, {
|
||||
staticDir: config.staticDir,
|
||||
knownWordCachePath: config.knownWordCachePath,
|
||||
@@ -144,20 +186,26 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
||||
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
|
||||
});
|
||||
|
||||
const bunRuntime = globalThis as typeof globalThis & {
|
||||
Bun?: {
|
||||
serve?: (options: { fetch: (typeof app)['fetch']; port: number; hostname: string }) => {
|
||||
stop: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
if (bunRuntime.Bun?.serve) {
|
||||
const server = bunRuntime.Bun.serve({
|
||||
if (runtime.bunServe) {
|
||||
const server = runtime.bunServe({
|
||||
fetch: app.fetch,
|
||||
port: config.port,
|
||||
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);
|
||||
}
|
||||
|
||||
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 * as path from 'path';
|
||||
import { createLogger } from '../../logger.js';
|
||||
import type { WindowGeometry } from '../../types.js';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
||||
import {
|
||||
@@ -26,9 +27,11 @@ import {
|
||||
} from './stats-window-layer.js';
|
||||
|
||||
let statsWindow: BrowserWindow | null = null;
|
||||
let statsWindowGeneration = 0;
|
||||
let toggleRegistered = false;
|
||||
let nativeDialogLayerRegistered = false;
|
||||
const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState();
|
||||
const logger = createLogger('main:stats-window');
|
||||
|
||||
export interface StatsWindowOptions {
|
||||
/** Absolute path to stats/dist/ directory */
|
||||
@@ -36,7 +39,9 @@ export interface StatsWindowOptions {
|
||||
/** Absolute path to the compiled preload-stats.js */
|
||||
preloadPath: string;
|
||||
/** 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 */
|
||||
getToggleKey: () => string;
|
||||
/** 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.
|
||||
* 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) {
|
||||
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(
|
||||
buildStatsWindowOptions({
|
||||
preloadPath: options.preloadPath,
|
||||
@@ -195,7 +208,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
});
|
||||
|
||||
const indexPath = path.join(options.staticDir, 'index.html');
|
||||
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(options.getApiBaseUrl?.()));
|
||||
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(apiBaseUrl));
|
||||
|
||||
statsWindow.on('closed', () => {
|
||||
options.onVisibilityChanged?.(false);
|
||||
@@ -243,7 +256,9 @@ export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
|
||||
if (toggleRegistered) return;
|
||||
toggleRegistered = true;
|
||||
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.
|
||||
*/
|
||||
export function destroyStatsWindow(): void {
|
||||
statsWindowGeneration += 1;
|
||||
if (statsWindow && !statsWindow.isDestroyed()) {
|
||||
statsWindow.destroy();
|
||||
statsWindow = null;
|
||||
|
||||
Reference in New Issue
Block a user