chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user