Files
qiming/qimingclaw/crates/agent-electron-client/src/main/processManager.ts

286 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn, ChildProcess } from "child_process";
import log from "electron-log";
import { PROCESS_KILL_ESCALATION_TIMEOUT } from "@shared/constants";
import { t } from "./services/i18n";
const STARTUP_STDERR_CAP = 8192;
const STARTUP_STDERR_IN_ERROR = 1200;
export class ManagedProcess {
private process: ChildProcess | null = null;
private lastError: string | null = null;
/** 当前这次 start() 收集的 stderr用于「启动后立即退出」时的错误详情 */
private startupStderr = "";
private startupExit: {
code: number | null;
signal: NodeJS.Signals | null;
} | null = null;
constructor(private readonly name: string) {}
start(config: {
command: string;
args: string[];
env?: Record<string, string>;
cwd?: string;
shell?: boolean;
stdio?: ("ignore" | "pipe")[];
startupDelayMs?: number;
/** 每行 stdout 的额外回调,在 log.info 之后调用 */
onStdoutLine?: (line: string) => void;
}): Promise<{ success: boolean; error?: string; message?: string }> {
if (this.process) {
this.lastError = null;
return Promise.resolve({ success: true, message: "Already running" });
}
return new Promise((resolve) => {
try {
this.startupStderr = "";
this.startupExit = null;
const proc = spawn(config.command, config.args, {
shell: config.shell ?? false,
windowsHide: true,
env: config.env ? { ...process.env, ...config.env } : process.env,
cwd: config.cwd,
stdio: (config.stdio as any) ?? ["ignore", "pipe", "pipe"],
});
proc.stdout?.on("data", (data: Buffer) => {
const line = data.toString().trim();
log.info(`[${this.name}]`, line);
config.onStdoutLine?.(line);
});
proc.stderr?.on("data", (data: Buffer) => {
const raw = data.toString();
const combined = this.startupStderr + raw;
this.startupStderr =
combined.length > STARTUP_STDERR_CAP
? combined.slice(-STARTUP_STDERR_CAP)
: combined;
log.warn(`[${this.name} stderr]`, raw.trim());
});
proc.on("error", (error) => {
log.error(`[${this.name}] Process error:`, error.message, {
stack: error.stack,
});
this.process = null;
this.lastError = error.message;
resolve({ success: false, error: error.message });
});
proc.on("exit", (code, signal) => {
this.startupExit = { code, signal: signal ?? null };
if (code !== 0 && code !== null) {
log.warn(
`[${this.name}] Process exited code=${code} signal=${signal ?? "none"}`,
{ lastError: this.lastError },
);
} else {
log.info(`[${this.name}] Process exited`, {
code,
signal: signal ?? "none",
});
}
this.process = null;
});
this.process = proc;
const delay = config.startupDelayMs ?? 1000;
setTimeout(() => {
if (this.process) {
this.lastError = null;
this.startupStderr = "";
this.startupExit = null;
resolve({ success: true });
} else {
const base = t("Claw.Process.exitImmediately");
const ex = this.startupExit;
const exitHint = ex
? ` (exit ${ex.code}, signal ${ex.signal ?? "none"})`
: "";
const tail = this.startupStderr.trim();
// Try to parse structured error prefix (e.g., GUI_AGENT_ERROR:{...})
let errorMsg: string;
// Extract first line matching GUI_AGENT_ERROR: prefix, then parse JSON from it
const errorLineMatch = tail
.split("\n")
.find((line) => line.startsWith("GUI_AGENT_ERROR:"));
if (errorLineMatch) {
const jsonStr = errorLineMatch.substring(
"GUI_AGENT_ERROR:".length,
);
try {
const errObj = JSON.parse(jsonStr);
errorMsg = errObj.message || errObj.code || "Unknown error";
} catch {
errorMsg = base + exitHint;
}
} else {
// Fallback: parse structured logs, extract [error] lines only
// Format: [YYYY-MM-DD HH:mm:ss.SSS] [level] [service] message
const errorLines = tail
.split("\n")
.filter(
(line) =>
/\]\s*\[error\]/i.test(line) || /\[error\]\s*/i.test(line),
)
.map((line) => {
// Strip timestamp and log level prefix for cleaner output
const msgMatch = line.match(
/(\[[\d:.\s]+\]\s*)?\[[^\]]+\]\s*\[error\]\s*(.*)/i,
);
return msgMatch ? msgMatch[2] : line;
})
.slice(-2); // Keep last 2 error lines
const filteredTail = errorLines.join("; ");
errorMsg =
filteredTail.length > 0
? `: ${filteredTail.length > STARTUP_STDERR_IN_ERROR ? "…" + filteredTail.slice(-STARTUP_STDERR_IN_ERROR) : filteredTail}`
: "";
}
const msg = `${base}${exitHint}${errorMsg}`;
this.lastError = msg;
log.warn(`[${this.name}] Start failed: ${msg}`, {
command: config.command,
args: config.args,
});
resolve({ success: false, error: msg });
}
}, delay);
} catch (error) {
this.process = null;
this.lastError = String(error);
log.error(
`[${this.name}] Start exception:`,
error instanceof Error ? error.message : String(error),
{ stack: error instanceof Error ? error.stack : undefined },
);
resolve({ success: false, error: String(error) });
}
});
}
stop(): { success: boolean; message?: string } {
this.lastError = null;
if (this.process) {
const proc = this.process;
this.process = null;
// Remove all event listeners to prevent handle leaks (matching kill() behavior)
proc.stdout?.removeAllListeners();
proc.stderr?.removeAllListeners();
proc.stdin?.removeAllListeners();
proc.removeAllListeners();
proc.kill();
return { success: true };
}
return { success: true, message: "Not running" };
}
/**
* 异步停止进程,等待其真正退出后再返回。
* 用于需要确保端口/资源已释放后再重启的场景(如 lanproxy 切换账号)。
*/
stopAsync(timeoutMs = 5000): Promise<{ success: boolean; message?: string }> {
this.lastError = null;
if (!this.process) {
return Promise.resolve({ success: true, message: "Not running" });
}
const proc = this.process;
this.process = null;
// 与 kill() 保持一致:清理 stdio 监听器,防止 Windows 句柄泄漏。
proc.stdout?.removeAllListeners();
proc.stderr?.removeAllListeners();
proc.stdin?.removeAllListeners();
// 移除 start() 注册的 exit handler防止旧进程退出时错误清空新进程引用。
proc.removeAllListeners("exit");
return new Promise((resolve) => {
const timer = setTimeout(() => {
try {
proc.kill("SIGKILL");
} catch {
/* ignore */
}
log.warn(
`[${this.name}] stopAsync: process did not exit within ${timeoutMs}ms, force killed`,
);
resolve({ success: true, message: "Force killed after timeout" });
}, timeoutMs);
proc.once("exit", () => {
clearTimeout(timer);
resolve({ success: true });
});
proc.kill();
});
}
status(): { running: boolean; pid?: number; error?: string } {
return {
running: this.process !== null,
pid: this.process?.pid,
error:
this.process === null && this.lastError ? this.lastError : undefined,
};
}
kill(): void {
if (this.process) {
const proc = this.process;
this.process = null;
try {
// 🔧 FIX: Remove all event listeners to prevent handle leaks
// Event listeners maintain references to stdout/stderr streams
// which prevents Windows from releasing file handles
proc.stdout?.removeAllListeners();
proc.stderr?.removeAllListeners();
proc.stdin?.removeAllListeners();
proc.removeAllListeners();
proc.kill();
log.info(`[Cleanup] ${this.name} sent SIGTERM`);
// Escalate to SIGKILL if process doesn't exit in time
const escalationTimer = setTimeout(() => {
try {
if (proc.pid) {
process.kill(proc.pid, "SIGKILL");
log.warn(
`[Cleanup] ${this.name} escalated to SIGKILL after ${PROCESS_KILL_ESCALATION_TIMEOUT}ms`,
);
}
} catch {
// Process already exited, ignore
}
}, PROCESS_KILL_ESCALATION_TIMEOUT);
// Clear timer if process exits promptly
proc.once("exit", () => {
clearTimeout(escalationTimer);
log.info(`[Cleanup] ${this.name} exited`);
});
} catch (e) {
log.error(`[Cleanup] ${this.name} stop error:`, e);
}
}
this.lastError = null;
}
get running(): boolean {
return this.process !== null;
}
get pid(): number | undefined {
return this.process?.pid;
}
}