mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
Sync Stats & History window, headless --sync-cli, and Windows remote support (#160)
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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 =
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user