chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -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;
}
}