mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 01:55:51 -07:00
82f6b4705a
- Refresh the hidden modal renderer between Windows sessions - Add regression coverage and stabilize launcher completion testing
222 lines
7.8 KiB
TypeScript
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,
|
|
};
|
|
}
|