Files
SubMiner/src/main/runtime/sync-launcher-client.ts
T
sudacode 82f6b4705a fix(overlay): recycle Windows modal windows after close
- Refresh the hidden modal renderer between Windows sessions
- Add regression coverage and stabilize launcher completion testing
2026-08-16 01:10:07 -07:00

222 lines
7.8 KiB
TypeScript

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<SyncLauncherRunResult>;
}
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<SyncProgressEvent, { type: 'result' }> | 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<typeof setTimeout> | null = null;
let operationTimer: ReturnType<typeof setTimeout> | null = null;
let drainTimer: ReturnType<typeof setTimeout> | 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<SyncLauncherRunResult>((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,
};
}