import { spawn as nodeSpawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events'; import { SYNC_CLI_FLAG } from '../../core/services/stats-sync/cli-args'; /** How long a cancelled sync child gets to exit on SIGTERM before SIGKILL. */ const CANCEL_GRACE_MS = 5000; /** * How long a child that exited without terminal NDJSON gets to flush its * remaining stdout before the exit is treated as authoritative. */ const EXIT_DRAIN_MS = 2000; export interface SyncLauncherChildLike { stdout: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null; stderr: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null; on(event: 'close', listener: (code: number | null, signal: string | null) => void): unknown; on(event: 'exit', listener: (code: number | null, signal: string | null) => void): unknown; on(event: 'error', listener: (error: Error) => void): unknown; kill(signal?: NodeJS.Signals): boolean; } export type SyncLauncherSpawn = (command: string, args: string[]) => SyncLauncherChildLike; export interface SyncLauncherRunResult { ok: boolean; error: string | null; } export interface SyncLauncherRunHandle { cancel: () => void; done: Promise; } export function sanitizeSyncLauncherEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env = { ...baseEnv }; delete env.ELECTRON_RUN_AS_NODE; delete env.SUBMINER_APP_ARGC; for (const name of Object.keys(env)) { if (name.startsWith('SUBMINER_APP_ARG_')) delete env[name]; } return env; } // Sync runs in a child copy of this app in headless --sync-cli mode: same // engine and NDJSON protocol as `subminer sync --json`, with no dependency on // bun or an installed command-line launcher. In dev runs process.execPath is // a bare electron binary, so the app path is passed as its entry argument. export function resolveSyncLauncherCommand( deps: { execPath?: string; appPath?: string | null; } = {}, ): string[] { const execPath = deps.execPath ?? process.execPath; const appPath = deps.appPath ?? null; return appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG]; } export function runSyncLauncher(options: { command: string[]; args: string[]; onEvent: (event: SyncProgressEvent) => void; onStderr?: (text: string) => void; spawn?: SyncLauncherSpawn; timeoutMs?: number; }): SyncLauncherRunHandle { const spawn: SyncLauncherSpawn = options.spawn ?? ((command, args) => { // The child must boot as a full Electron app (its entry handles // --sync-cli); a leaked ELECTRON_RUN_AS_NODE would turn it into node. const env = sanitizeSyncLauncherEnv(process.env); return nodeSpawn(command, args, { stdio: 'pipe', env }); }); const [executable, ...prefixArgs] = options.command; const child = spawn(executable!, [...prefixArgs, ...options.args]); let stdoutBuffer = ''; let stderrTail = ''; let resultEvent: Extract | null = null; let terminationError: string | null = null; let settleAfterTerminalEvent: (() => void) | null = null; let settleAfterTermination: (() => boolean) | null = null; // Decode incrementally: a multibyte character can straddle a chunk boundary, // and a per-chunk toString() would corrupt it (sync payloads carry Japanese // media titles and error detail). const stdoutDecoder = new StringDecoder('utf8'); const stderrDecoder = new StringDecoder('utf8'); const decodeChunk = (decoder: StringDecoder, chunk: Buffer | string): string => typeof chunk === 'string' ? chunk : decoder.write(chunk); child.stdout?.on('data', (chunk) => { stdoutBuffer += decodeChunk(stdoutDecoder, chunk); let newlineIndex = stdoutBuffer.indexOf('\n'); while (newlineIndex !== -1) { const line = stdoutBuffer.slice(0, newlineIndex); stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); const event = parseSyncProgressLine(line); if (event) { if (event.type === 'result') { resultEvent = event; settleAfterTerminalEvent?.(); } options.onEvent(event); } newlineIndex = stdoutBuffer.indexOf('\n'); } }); child.stderr?.on('data', (chunk) => { const text = decodeChunk(stderrDecoder, chunk); stderrTail = `${stderrTail}${text}`.slice(-4000); options.onStderr?.(text); }); let killTimer: ReturnType | null = null; let operationTimer: ReturnType | null = null; let drainTimer: ReturnType | null = null; const clearTimers = (): void => { if (killTimer !== null) clearTimeout(killTimer); if (operationTimer !== null) clearTimeout(operationTimer); if (drainTimer !== null) clearTimeout(drainTimer); killTimer = null; operationTimer = null; drainTimer = null; }; const done = new Promise((resolve) => { let settled = false; let exitObserved = false; let exitCode: number | null = null; const settle = (result: SyncLauncherRunResult): void => { if (settled) return; settled = true; clearTimers(); resolve(result); }; child.on('error', (error) => { settle({ ok: false, error: error.message }); }); const settleFromExit = (code: number | null) => { if (terminationError) { settle({ ok: false, error: terminationError }); return; } if (code === 0) { settle({ ok: true, error: null }); return; } const error = resultEvent?.error ?? (stderrTail.trim() || `Launcher exited with code ${code ?? 'null'}.`); settle({ ok: false, error }); }; settleAfterTerminalEvent = () => { if (exitObserved) settleFromExit(exitCode); }; settleAfterTermination = () => { if (!exitObserved) return false; settleFromExit(exitCode); return true; }; // Electron descendants can retain inherited stdio pipes after the main // child exits, delaying `close` indefinitely. Once terminal NDJSON has // arrived, `exit` is authoritative; keep `close` as the fallback. child.on('exit', (code) => { exitObserved = true; exitCode = code; if (resultEvent || terminationError) { settleFromExit(code); return; } // No terminal event yet: stdout may still be flushing, so give it a // bounded window rather than waiting on a `close` that retained pipes // can withhold forever. drainTimer = setTimeout(() => { drainTimer = null; settleFromExit(code); }, EXIT_DRAIN_MS); drainTimer.unref?.(); }); child.on('close', settleFromExit); }); // A sync child blocked on an ssh password prompt may ignore SIGTERM, so // escalate to SIGKILL after a grace period. const terminate = (error: string): void => { if (terminationError) return; terminationError = error; // A child that already exited without terminal NDJSON is still waiting on // `close`, which descendants holding inherited pipes can delay // indefinitely. There is nothing left to signal, so settle now instead of // leaving `done` (and the quit-time shutdown that awaits it) pending. if (settleAfterTermination?.()) return; killTimer = setTimeout(() => { killTimer = null; try { child.kill('SIGKILL'); } catch { // process may already be gone } }, CANCEL_GRACE_MS); killTimer.unref?.(); try { child.kill('SIGTERM'); } catch { // process may already be gone } }; if (options.timeoutMs !== undefined) { operationTimer = setTimeout(() => terminate('Sync operation timed out.'), options.timeoutMs); operationTimer.unref?.(); } return { cancel: () => terminate('Sync cancelled.'), done, }; }