fix(startup): replace fixed overlay sleeps with readiness retries

This commit is contained in:
2026-02-27 21:02:21 -08:00
parent d6c4a85a3b
commit 17fa10ba36
5 changed files with 728 additions and 5 deletions

View File

@@ -4,7 +4,8 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { EventEmitter } from 'node:events';
import { waitForUnixSocketReady } from './mpv';
import type { Args } from './types';
import { startOverlay, state, waitForUnixSocketReady } from './mpv';
import * as mpvModule from './mpv';
function createTempSocketPath(): { dir: string; socketPath: string } {
@@ -59,3 +60,82 @@ test('waitForUnixSocketReady returns true when socket becomes connectable before
fs.rmSync(dir, { recursive: true, force: true });
}
});
function makeArgs(overrides: Partial<Args> = {}): Args {
return {
backend: 'x11',
directory: '.',
recursive: false,
profile: '',
startOverlay: false,
youtubeSubgenMode: 'off',
whisperBin: '',
whisperModel: '',
youtubeSubgenOutDir: '',
youtubeSubgenAudioFormat: 'wav',
youtubeSubgenKeepTemp: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
youtubeWhisperSourceLanguage: 'ja',
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
useRofi: false,
logLevel: 'error',
passwordStore: '',
target: '',
targetKind: '',
jimakuApiKey: '',
jimakuApiKeyCommand: '',
jimakuApiBaseUrl: '',
jimakuLanguagePreference: 'none',
jimakuMaxEntryResults: 10,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinPlay: false,
jellyfinDiscovery: false,
doctor: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
appPassthrough: false,
appArgs: [],
jellyfinServer: '',
jellyfinUsername: '',
jellyfinPassword: '',
...overrides,
};
}
test('startOverlay resolves without fixed 2s sleep when readiness signals arrive quickly', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(socketPath, '');
const originalCreateConnection = net.createConnection;
try {
net.createConnection = (() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => socket) as net.Socket['destroy'];
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
setTimeout(() => socket.emit('connect'), 10);
return socket;
}) as typeof net.createConnection;
const startedAt = Date.now();
await startOverlay(appPath, makeArgs(), socketPath);
const elapsedMs = Date.now() - startedAt;
assert.ok(elapsedMs < 1200, `expected startOverlay <1200ms, got ${elapsedMs}ms`);
} finally {
net.createConnection = originalCreateConnection;
state.overlayProc = null;
state.overlayManagedByLauncher = false;
fs.rmSync(dir, { recursive: true, force: true });
}
});

View File

@@ -28,6 +28,8 @@ export const state = {
};
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
function readTrackedDetachedMpvPid(): number | null {
try {
@@ -498,7 +500,47 @@ export function startMpv(
state.mpvProc = spawn('mpv', mpvArgs, { stdio: 'inherit' });
}
export function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> {
async function waitForOverlayStartCommandSettled(
proc: ReturnType<typeof spawn>,
logLevel: LogLevel,
timeoutMs: number,
): Promise<void> {
await new Promise<void>((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
proc.off('exit', onExit);
proc.off('error', onError);
resolve();
};
const onExit = (code: number | null) => {
if (typeof code === 'number' && code !== 0) {
log('warn', logLevel, `Overlay start command exited with status ${code}`);
}
finish();
};
const onError = (error: Error) => {
log('warn', logLevel, `Overlay start command failed: ${error.message}`);
finish();
};
proc.once('exit', onExit);
proc.once('error', onError);
timer = setTimeout(finish, timeoutMs);
if (proc.exitCode !== null && proc.exitCode !== undefined) {
onExit(proc.exitCode);
}
});
}
export async function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> {
const backend = detectBackend(args.backend);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
@@ -512,9 +554,22 @@ export function startOverlay(appPath: string, args: Args, socketPath: string): P
});
state.overlayManagedByLauncher = true;
return new Promise((resolve) => {
setTimeout(resolve, 2000);
});
const [socketReady] = await Promise.all([
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
waitForOverlayStartCommandSettled(
state.overlayProc,
args.logLevel,
OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS,
),
]);
if (!socketReady) {
log(
'debug',
args.logLevel,
'Overlay start continuing before mpv socket readiness was confirmed',
);
}
}
export function launchTexthookerOnly(appPath: string, args: Args): never {