diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 80cbd71e..76ae2271 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcess } from "child_process" +import { spawn, spawnSync, type ChildProcess } from "child_process" import { app } from "electron" import { createRequire } from "module" import { EventEmitter } from "events" @@ -82,6 +82,7 @@ export class CliProcessManager extends EventEmitter { private stdoutBuffer = "" private stderrBuffer = "" private bootstrapToken: string | null = null + private requestedStop = false async start(options: StartOptions): Promise { if (this.child) { @@ -91,6 +92,7 @@ export class CliProcessManager extends EventEmitter { this.stdoutBuffer = "" this.stderrBuffer = "" this.bootstrapToken = null + this.requestedStop = false this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) const cliEntry = this.resolveCliEntry(options) @@ -109,11 +111,13 @@ export class CliProcessManager extends EventEmitter { ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`) : this.buildDirectSpawn(cliEntry, args) + const detached = process.platform !== "win32" const child = spawn(spawnDetails.command, spawnDetails.args, { cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"], env, shell: false, + detached, }) console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`) @@ -175,12 +179,89 @@ export class CliProcessManager extends EventEmitter { return } + this.requestedStop = true + + const pid = child.pid + if (!pid) { + this.child = undefined + this.updateStatus({ state: "stopped" }) + return + } + + const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null + + const tryKillPosixGroup = (signal: NodeJS.Signals) => { + try { + // Negative PID targets the process group (POSIX). + process.kill(-pid, signal) + return true + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err?.code === "ESRCH") { + return true + } + return false + } + } + + const tryKillSinglePid = (signal: NodeJS.Signals) => { + try { + process.kill(pid, signal) + return true + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err?.code === "ESRCH") { + return true + } + return false + } + } + + const tryTaskkill = (force: boolean) => { + const args = ["/PID", String(pid), "/T"] + if (force) { + args.push("/F") + } + + try { + const result = spawnSync("taskkill", args, { encoding: "utf8" }) + const exitCode = result.status + if (exitCode === 0) { + return true + } + + // If the PID is already gone, treat it as success. + const stderr = (result.stderr ?? "").toString().toLowerCase() + const stdout = (result.stdout ?? "").toString().toLowerCase() + const combined = `${stdout}\n${stderr}` + if (combined.includes("not found") || combined.includes("no running instance")) { + return true + } + return false + } catch { + return false + } + } + + const sendStopSignal = (signal: NodeJS.Signals) => { + if (process.platform === "win32") { + tryTaskkill(signal === "SIGKILL") + return + } + + // Prefer process-group signaling so wrapper launchers (shell/tsx) don't outlive Electron. + const groupOk = tryKillPosixGroup(signal) + if (!groupOk) { + tryKillSinglePid(signal) + } + } + return new Promise((resolve) => { const killTimeout = setTimeout(() => { console.warn( `[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`, ) - child.kill("SIGKILL") + sendStopSignal("SIGKILL") }, 30000) child.on("exit", () => { @@ -191,7 +272,15 @@ export class CliProcessManager extends EventEmitter { resolve() }) - child.kill("SIGTERM") + if (isAlreadyExited()) { + clearTimeout(killTimeout) + this.child = undefined + this.updateStatus({ state: "stopped" }) + resolve() + return + } + + sendStopSignal("SIGTERM") }) } @@ -205,7 +294,16 @@ export class CliProcessManager extends EventEmitter { private handleTimeout() { if (this.child) { - this.child.kill("SIGKILL") + const pid = this.child.pid + if (pid && process.platform !== "win32") { + try { + process.kill(-pid, "SIGKILL") + } catch { + this.child.kill("SIGKILL") + } + } else { + this.child.kill("SIGKILL") + } this.child = undefined } this.updateStatus({ state: "error", error: "CLI did not start in time" })