From 7b3068635a3636b0006d736b62adf23855308e9c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 21 Oct 2026 05:24:07 -0400 Subject: [PATCH 1/6] fix(desktop): stop stale dev runners from crash-looping Electron Signed-off-by: Yordis Prieto --- .../tasks/dev/{desktop => desktop/_default} | 0 .config/mise/tasks/dev/desktop/reset | 7 ++ .config/mise/tasks/dev/desktop/stop | 5 + apps/desktop/scripts/dev-electron.mjs | 34 ++++++ apps/desktop/scripts/dev-runner-control.mjs | 104 ++++++++++++++++++ apps/desktop/scripts/dev-runner-guards.mjs | 44 ++++++++ .../scripts/dev-runner-guards.test.mjs | 80 ++++++++++++++ apps/desktop/scripts/electron-launcher.mjs | 25 ++++- .../scripts/electron-launcher.test.mjs | 20 ++++ apps/desktop/scripts/stop-dev-electron.mjs | 18 +++ docs/operations/development.md | 6 + 11 files changed, 340 insertions(+), 3 deletions(-) rename .config/mise/tasks/dev/{desktop => desktop/_default} (100%) create mode 100755 .config/mise/tasks/dev/desktop/reset create mode 100755 .config/mise/tasks/dev/desktop/stop create mode 100644 apps/desktop/scripts/dev-runner-control.mjs create mode 100644 apps/desktop/scripts/dev-runner-guards.mjs create mode 100644 apps/desktop/scripts/dev-runner-guards.test.mjs create mode 100644 apps/desktop/scripts/stop-dev-electron.mjs diff --git a/.config/mise/tasks/dev/desktop b/.config/mise/tasks/dev/desktop/_default similarity index 100% rename from .config/mise/tasks/dev/desktop rename to .config/mise/tasks/dev/desktop/_default diff --git a/.config/mise/tasks/dev/desktop/reset b/.config/mise/tasks/dev/desktop/reset new file mode 100755 index 000000000000..894c5856465e --- /dev/null +++ b/.config/mise/tasks/dev/desktop/reset @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +#MISE description="Stop this checkout's desktop dev runner and delete its Electron bundle so the next start rebuilds it" +#MISE depends=["dev:desktop:stop"] +set -euo pipefail +cd "$MISE_PROJECT_ROOT" +rm -rf apps/desktop/.electron-runtime +echo "[desktop-dev] removed apps/desktop/.electron-runtime" diff --git a/.config/mise/tasks/dev/desktop/stop b/.config/mise/tasks/dev/desktop/stop new file mode 100755 index 000000000000..a995c86097ff --- /dev/null +++ b/.config/mise/tasks/dev/desktop/stop @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +#MISE description="Stop this checkout's desktop dev runner" +set -euo pipefail +cd "$MISE_PROJECT_ROOT" +node apps/desktop/scripts/stop-dev-electron.mjs diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 07578297f06d..db807a772671 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -8,6 +8,12 @@ import { resolveDevProtocolClient, resolveElectronLaunchCommand, } from "./electron-launcher.mjs"; +import { + claimRunnerRecord, + releaseRunnerRecord, + stopPreviousRunner, +} from "./dev-runner-control.mjs"; +import { createCrashLoopBreaker } from "./dev-runner-guards.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); @@ -49,10 +55,19 @@ const watchedDirectories = [ const forcedShutdownTimeoutMs = 1_500; const restartDebounceMs = 120; const childTreeGracePeriodMs = 1_200; +const orphanCheckIntervalMs = 1_000; const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim(); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. const hostPlatform = NodeOS.platform(); +// Two runners in one checkout rebuild the same launcher bundle concurrently and kill each +// other's Electron processes, so a new runner replaces the previous one. +const { stoppedPid } = await stopPreviousRunner(desktopDir); +if (stoppedPid !== null) { + console.log(`[desktop-dev] stopped previous runner ${stoppedPid} for this checkout`); +} +claimRunnerRecord(desktopDir); + NodeChildProcess.execFileSync( process.execPath, [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")], @@ -80,6 +95,8 @@ let currentApp = null; let restartQueue = Promise.resolve(); const expectedExits = new WeakSet(); const watchers = []; +const crashLoopBreaker = createCrashLoopBreaker(); +const initialParentPid = process.ppid; function killChildTreeByPid(pid, signal) { if (hostPlatform === "win32" || typeof pid !== "number") { @@ -141,6 +158,14 @@ function startApp() { const exitedAbnormally = signal !== null || code !== 0; if (!shuttingDown && !expectedExits.has(app) && exitedAbnormally) { + if (crashLoopBreaker.recordAbnormalExit(Date.now()).tripped) { + console.error( + "[desktop-dev] Electron keeps crashing right after launch, so restarts are paused. " + + "Fix the problem and rebuild to try again, or run `mise run dev:desktop:reset` " + + "to rebuild the Electron bundle.", + ); + return; + } scheduleRestart(); } }); @@ -217,6 +242,7 @@ function startWatchers() { return; } + crashLoopBreaker.rearm(); scheduleRestart(); }, ); @@ -248,6 +274,8 @@ async function shutdown(exitCode) { watcher.close(); } + clearInterval(orphanCheck); + releaseRunnerRecord(desktopDir); await stopApp(); killChildTree("TERM"); await new Promise((resolve) => { @@ -258,6 +286,12 @@ async function shutdown(exitCode) { process.exit(exitCode); } +const orphanCheck = setInterval(() => { + if (process.ppid !== initialParentPid) { + void shutdown(0); + } +}, orphanCheckIntervalMs); + startWatchers(); cleanupStaleDevApps(); startApp(); diff --git a/apps/desktop/scripts/dev-runner-control.mjs b/apps/desktop/scripts/dev-runner-control.mjs new file mode 100644 index 000000000000..ac35536ee497 --- /dev/null +++ b/apps/desktop/scripts/dev-runner-control.mjs @@ -0,0 +1,104 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { decideTakeover, parseRunnerRecord } from "./dev-runner-guards.mjs"; + +const stopGracePeriodMs = 4_000; +const stopPollIntervalMs = 100; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. +const hostPlatform = NodeOS.platform(); + +export function runnerRecordPath(desktopDir) { + return NodePath.join(desktopDir, ".electron-runtime", "runner.json"); +} + +function readRunnerRecord(desktopDir) { + try { + return parseRunnerRecord(NodeFS.readFileSync(runnerRecordPath(desktopDir), "utf8")); + } catch { + return null; + } +} + +function readProcessCwd(pid) { + if (hostPlatform === "linux") { + try { + return NodeFS.readlinkSync(`/proc/${pid}/cwd`); + } catch { + return null; + } + } + const result = NodeChildProcess.spawnSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { + encoding: "utf8", + }); + const line = result.stdout?.split("\n").find((entry) => entry.startsWith("n")); + return line ? line.slice(1) : null; +} + +function inspectProcess(pid) { + const result = NodeChildProcess.spawnSync("ps", ["-p", String(pid), "-o", "command="], { + encoding: "utf8", + }); + const command = result.stdout?.trim(); + if (result.status !== 0 || !command) { + return null; + } + return { command, cwd: readProcessCwd(pid) }; +} + +function isAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +async function waitForExit(pid, timeoutMs) { + for (let waited = 0; waited < timeoutMs; waited += stopPollIntervalMs) { + if (!isAlive(pid)) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, stopPollIntervalMs)); + } + return !isAlive(pid); +} + +export async function stopPreviousRunner(desktopDir, selfPid = process.pid) { + if (hostPlatform === "win32") { + return { stoppedPid: null }; + } + + const record = readRunnerRecord(desktopDir); + const decision = decideTakeover({ + record, + selfPid, + processInfo: record === null ? null : inspectProcess(record.pid), + desktopDir, + }); + if (decision.action !== "stop") { + return { stoppedPid: null, refusedPid: decision.action === "refuse" ? decision.pid : null }; + } + + process.kill(decision.pid, "SIGTERM"); + if (!(await waitForExit(decision.pid, stopGracePeriodMs))) { + process.kill(decision.pid, "SIGKILL"); + await waitForExit(decision.pid, stopGracePeriodMs); + } + return { stoppedPid: decision.pid }; +} + +export function claimRunnerRecord(desktopDir, pid = process.pid) { + NodeFS.mkdirSync(NodePath.dirname(runnerRecordPath(desktopDir)), { recursive: true }); + NodeFS.writeFileSync(runnerRecordPath(desktopDir), `${JSON.stringify({ pid })}\n`); +} + +export function releaseRunnerRecord(desktopDir, pid = process.pid) { + if (readRunnerRecord(desktopDir)?.pid === pid) { + NodeFS.rmSync(runnerRecordPath(desktopDir), { force: true }); + } +} diff --git a/apps/desktop/scripts/dev-runner-guards.mjs b/apps/desktop/scripts/dev-runner-guards.mjs new file mode 100644 index 000000000000..4585349b9751 --- /dev/null +++ b/apps/desktop/scripts/dev-runner-guards.mjs @@ -0,0 +1,44 @@ +export const RUNNER_SCRIPT_NAME = "dev-electron.mjs"; + +export function parseRunnerRecord(text) { + let parsed; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + const pid = parsed?.pid; + return Number.isInteger(pid) && pid > 1 ? { pid } : null; +} + +export function isVerifiedRunner(processInfo, desktopDir) { + return ( + processInfo !== null && + processInfo.command.includes(RUNNER_SCRIPT_NAME) && + processInfo.cwd === desktopDir + ); +} + +// `processInfo` is null when the pid is not alive. A live pid that fails verification +// is never signaled because the pid may have been reused by an unrelated process. +export function decideTakeover({ record, selfPid, processInfo, desktopDir }) { + if (record === null || record.pid === selfPid || processInfo === null) { + return { action: "none" }; + } + return isVerifiedRunner(processInfo, desktopDir) + ? { action: "stop", pid: record.pid } + : { action: "refuse", pid: record.pid }; +} + +export function createCrashLoopBreaker({ maxAbnormalExits = 3, windowMs = 10_000 } = {}) { + let exitTimes = []; + return { + recordAbnormalExit(nowMs) { + exitTimes = [...exitTimes.filter((time) => nowMs - time < windowMs), nowMs]; + return { tripped: exitTimes.length >= maxAbnormalExits }; + }, + rearm() { + exitTimes = []; + }, + }; +} diff --git a/apps/desktop/scripts/dev-runner-guards.test.mjs b/apps/desktop/scripts/dev-runner-guards.test.mjs new file mode 100644 index 000000000000..d28d815ae461 --- /dev/null +++ b/apps/desktop/scripts/dev-runner-guards.test.mjs @@ -0,0 +1,80 @@ +import { assert, describe, it } from "vite-plus/test"; + +import { + createCrashLoopBreaker, + decideTakeover, + isVerifiedRunner, + parseRunnerRecord, +} from "./dev-runner-guards.mjs"; + +const desktopDir = "/work/a/apps/desktop"; +const runner = { command: "node scripts/dev-electron.mjs", cwd: desktopDir }; + +describe("runner record", () => { + it("accepts a pid and rejects malformed or unsafe records", () => { + assert.deepEqual(parseRunnerRecord('{"pid": 4242}'), { pid: 4242 }); + for (const text of ["", "not json", "{}", '{"pid":"7"}', '{"pid":1}', '{"pid":-3}']) { + assert.isNull(parseRunnerRecord(text)); + } + }); +}); + +describe("takeover decision", () => { + const decide = (overrides) => + decideTakeover({ + record: { pid: 100 }, + selfPid: 200, + processInfo: runner, + desktopDir, + ...overrides, + }); + + it("stops a live runner from the same checkout", () => { + assert.deepEqual(decide({}), { action: "stop", pid: 100 }); + }); + + it("does nothing without a record, for a dead pid, or for itself", () => { + assert.deepEqual(decide({ record: null }), { action: "none" }); + assert.deepEqual(decide({ processInfo: null }), { action: "none" }); + assert.deepEqual(decide({ selfPid: 100 }), { action: "none" }); + }); + + it("refuses a reused pid that is not this checkout's runner", () => { + assert.deepEqual(decide({ processInfo: { command: "sleep 30", cwd: desktopDir } }), { + action: "refuse", + pid: 100, + }); + assert.deepEqual(decide({ processInfo: { ...runner, cwd: "/work/b/apps/desktop" } }), { + action: "refuse", + pid: 100, + }); + assert.isFalse(isVerifiedRunner({ ...runner, cwd: null }, desktopDir)); + }); +}); + +describe("crash loop breaker", () => { + it("trips on three abnormal exits inside the window", () => { + const breaker = createCrashLoopBreaker(); + assert.isFalse(breaker.recordAbnormalExit(0).tripped); + assert.isFalse(breaker.recordAbnormalExit(4_000).tripped); + assert.isTrue(breaker.recordAbnormalExit(9_000).tripped); + }); + + it("forgets exits that fall outside the window", () => { + const breaker = createCrashLoopBreaker(); + breaker.recordAbnormalExit(0); + breaker.recordAbnormalExit(5_000); + assert.isFalse(breaker.recordAbnormalExit(10_000).tripped); + assert.isFalse(breaker.recordAbnormalExit(30_000).tripped); + }); + + it("starts counting again after a rearm", () => { + const breaker = createCrashLoopBreaker(); + breaker.recordAbnormalExit(0); + breaker.recordAbnormalExit(1_000); + breaker.rearm(); + assert.isFalse(breaker.recordAbnormalExit(2_000).tripped); + assert.isFalse(breaker.recordAbnormalExit(3_000).tripped); + assert.isTrue(breaker.recordAbnormalExit(4_000).tripped); + }); +}); diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 0fb4377f07be..99bbcd26f892 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -13,12 +13,30 @@ const repoRoot = NodePath.resolve(desktopDir, "..", ".."); const devBundleIdSuffix = NodePath.basename(repoRoot) .toLowerCase() .replaceAll(/[^a-z0-9]+/g, ""); +export function resolveWorktreeLabel(folderName) { + return folderName.replace(/^t3code-(?:wt-)?/, "") || folderName; +} + +function isLinkedWorktree() { + try { + return NodeFS.statSync(NodePath.join(repoRoot, ".git")).isFile(); + } catch { + return false; + } +} + const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Beta)"; +// Only the Dock and app switcher label differs. The bundle path, executable names, bundle id and +// the app's own name and userData are derived from the fixed names above. +const APP_DOCK_NAME = + isDevelopment && isLinkedWorktree() + ? `T3 Code (Dev \u00b7 ${resolveWorktreeLabel(NodePath.basename(repoRoot))})` + : APP_DISPLAY_NAME; const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` : "com.t3tools.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; -const LAUNCHER_VERSION = 20; +const LAUNCHER_VERSION = 21; const developmentMacIconPngPath = NodePath.join( repoRoot, "assets", @@ -262,8 +280,8 @@ function ensureMacIconIcns(runtimeDir) { function resolveMacBundleInfoPlistStrings(executableName) { return { - CFBundleDisplayName: APP_DISPLAY_NAME, - CFBundleName: APP_DISPLAY_NAME, + CFBundleDisplayName: APP_DOCK_NAME, + CFBundleName: APP_DOCK_NAME, CFBundleIdentifier: APP_BUNDLE_ID, CFBundleExecutable: executableName, CFBundleIconFile: "icon.icns", @@ -361,6 +379,7 @@ function buildMacLauncher(electronBinaryPath) { sourceAppMtimeMs: NodeFS.statSync(sourceAppBundlePath).mtimeMs, iconMtimeMs: NodeFS.statSync(iconPath).mtimeMs, appBundleId: APP_BUNDLE_ID, + appDockName: APP_DOCK_NAME, appProtocolSchemes: APP_PROTOCOL_SCHEMES, }; diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index fa6e56b5800e..6c5ef77a8164 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -12,6 +12,7 @@ import { makeDevelopmentLauncherScript, resolveElectronBinaryPath, resolveMacLauncherPaths, + resolveWorktreeLabel, writeDevelopmentLauncherScript, } from "./electron-launcher.mjs"; @@ -136,6 +137,15 @@ async function startupCleanupPattern(desktopDir) { NodeOS: { platform: () => "linux" }, NodePath, desktopDir, + stopPreviousRunner: async () => ({ stoppedPid: null }), + claimRunnerRecord: () => undefined, + releaseRunnerRecord: () => undefined, + createCrashLoopBreaker: () => ({ + recordAbnormalExit: () => ({ tripped: false }), + rearm: () => undefined, + }), + setInterval: () => 0, + clearInterval: () => undefined, resolveDevProtocolClient: () => null, resolveElectronLaunchCommand: (args) => ({ electronPath: "electron", args }), waitForResources: async () => undefined, @@ -143,6 +153,7 @@ async function startupCleanupPattern(desktopDir) { process: { env: { VITE_DEV_SERVER_URL: "http://localhost:1234" }, execPath: "node", + ppid: 1, once: () => undefined, }, }, @@ -207,3 +218,12 @@ it ); } }); + +describe("worktree dock label", () => { + it("drops the checkout prefix and keeps the distinguishing name", () => { + assert.equal(resolveWorktreeLabel("t3code-wt-secret-management"), "secret-management"); + assert.equal(resolveWorktreeLabel("t3code-feature-x"), "feature-x"); + assert.equal(resolveWorktreeLabel("scratch"), "scratch"); + assert.equal(resolveWorktreeLabel("t3code-"), "t3code-"); + }); +}); diff --git a/apps/desktop/scripts/stop-dev-electron.mjs b/apps/desktop/scripts/stop-dev-electron.mjs new file mode 100644 index 000000000000..06d429ba681f --- /dev/null +++ b/apps/desktop/scripts/stop-dev-electron.mjs @@ -0,0 +1,18 @@ +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { releaseRunnerRecord, stopPreviousRunner } from "./dev-runner-control.mjs"; + +const desktopDir = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); + +const { stoppedPid, refusedPid } = await stopPreviousRunner(desktopDir, 0); +if (stoppedPid !== null) { + releaseRunnerRecord(desktopDir, stoppedPid); + console.log(`[desktop-dev] stopped runner ${stoppedPid} for this checkout`); +} else if (refusedPid) { + console.log( + `[desktop-dev] runner.json names pid ${refusedPid}, which is not a runner for this checkout; left it alone`, + ); +} else { + console.log("[desktop-dev] no runner is running for this checkout"); +} diff --git a/docs/operations/development.md b/docs/operations/development.md index bc4c0a58416b..58397a96cd6e 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -25,6 +25,12 @@ The server also loads the native resource monitor, which a fresh checkout has no `mise run dev:desktop` installs dependencies, builds `native/resource-monitor`, and then starts `vp run dev:desktop`. The tasks live in `.config/mise/tasks`. +The desktop runner keeps one instance per checkout: starting it stops a previous runner in the same +checkout, and a runner exits when its parent process dies. If Electron crashes repeatedly right +after launch, the runner pauses restarts until a watched file changes. `mise run dev:desktop:stop` +stops the runner for the current checkout, and `mise run dev:desktop:reset` also deletes +`apps/desktop/.electron-runtime` so the Electron bundle is rebuilt on the next start. + Flags go directly after the task name, for example `vp run dev --home-dir /tmp/t3code-dev`. Add `--browser` to open a browser automatically. From df6371a461268a87e9f76848e2eb432f540b312c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 21 Oct 2026 05:35:16 -0400 Subject: [PATCH 2/6] fix(desktop): close the gaps that let a stale dev runner survive or loop Signed-off-by: Yordis Prieto --- apps/desktop/package.json | 1 + apps/desktop/scripts/dev-electron.mjs | 44 ++++++++++++------- apps/desktop/scripts/dev-runner-control.mjs | 2 +- apps/desktop/scripts/dev-runner-guards.mjs | 16 +++++-- .../scripts/dev-runner-guards.test.mjs | 7 +++ apps/desktop/scripts/stop-dev-electron.mjs | 7 +++ 6 files changed, 57 insertions(+), 20 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fded4eec9e41..10e383a7bacc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,6 +7,7 @@ "scripts": { "ensure:electron": "node scripts/ensure-electron-runtime.mjs", "start": "node scripts/start-electron.mjs", + "dev:stop": "node scripts/stop-dev-electron.mjs", "typecheck": "tsc-rs --noEmit", "test": "vp test run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index db807a772671..061450b3d995 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -60,6 +60,8 @@ const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.tr // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. const hostPlatform = NodeOS.platform(); +const initialParentPid = process.ppid; + // Two runners in one checkout rebuild the same launcher bundle concurrently and kill each // other's Electron processes, so a new runner replaces the previous one. const { stoppedPid } = await stopPreviousRunner(desktopDir); @@ -81,6 +83,11 @@ await waitForResources({ tcpPort: port, }); +if (process.ppid !== initialParentPid) { + releaseRunnerRecord(desktopDir); + process.exit(0); +} + const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; const devProtocolClient = resolveDevProtocolClient(); @@ -95,8 +102,8 @@ let currentApp = null; let restartQueue = Promise.resolve(); const expectedExits = new WeakSet(); const watchers = []; +const failedApps = new WeakSet(); const crashLoopBreaker = createCrashLoopBreaker(); -const initialParentPid = process.ppid; function killChildTreeByPid(pid, signal) { if (hostPlatform === "win32" || typeof pid !== "number") { @@ -146,9 +153,7 @@ function startApp() { currentApp = null; } - if (!shuttingDown) { - scheduleRestart(); - } + restartAfterFailure(app); }); app.once("exit", (code, signal) => { @@ -156,21 +161,30 @@ function startApp() { currentApp = null; } - const exitedAbnormally = signal !== null || code !== 0; - if (!shuttingDown && !expectedExits.has(app) && exitedAbnormally) { - if (crashLoopBreaker.recordAbnormalExit(Date.now()).tripped) { - console.error( - "[desktop-dev] Electron keeps crashing right after launch, so restarts are paused. " + - "Fix the problem and rebuild to try again, or run `mise run dev:desktop:reset` " + - "to rebuild the Electron bundle.", - ); - return; - } - scheduleRestart(); + if (signal !== null || code !== 0) { + restartAfterFailure(app); } }); } +// A spawn error can be followed by an exit event for the same child, so each child counts once. +function restartAfterFailure(app) { + if (shuttingDown || expectedExits.has(app) || failedApps.has(app)) { + return; + } + failedApps.add(app); + + if (crashLoopBreaker.recordAbnormalExit(Date.now()).tripped) { + console.error( + "[desktop-dev] Electron keeps crashing right after launch, so restarts are paused. " + + "Fix the problem and rebuild to try again, or run `mise run dev:desktop:reset` " + + "to rebuild the Electron bundle.", + ); + return; + } + scheduleRestart(); +} + async function stopApp() { const app = currentApp; if (!app) { diff --git a/apps/desktop/scripts/dev-runner-control.mjs b/apps/desktop/scripts/dev-runner-control.mjs index ac35536ee497..68179e89f888 100644 --- a/apps/desktop/scripts/dev-runner-control.mjs +++ b/apps/desktop/scripts/dev-runner-control.mjs @@ -11,7 +11,7 @@ const stopPollIntervalMs = 100; // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. const hostPlatform = NodeOS.platform(); -export function runnerRecordPath(desktopDir) { +function runnerRecordPath(desktopDir) { return NodePath.join(desktopDir, ".electron-runtime", "runner.json"); } diff --git a/apps/desktop/scripts/dev-runner-guards.mjs b/apps/desktop/scripts/dev-runner-guards.mjs index 4585349b9751..207f96edd7ba 100644 --- a/apps/desktop/scripts/dev-runner-guards.mjs +++ b/apps/desktop/scripts/dev-runner-guards.mjs @@ -1,4 +1,4 @@ -export const RUNNER_SCRIPT_NAME = "dev-electron.mjs"; +const RUNNER_SCRIPT_NAME = "dev-electron.mjs"; export function parseRunnerRecord(text) { let parsed; @@ -11,11 +11,19 @@ export function parseRunnerRecord(text) { return Number.isInteger(pid) && pid > 1 ? { pid } : null; } +function isRunnerCommand(command) { + const [executable = "", ...args] = command.trim().split(/\s+/); + const script = args.find((arg) => !arg.startsWith("-")); + return ( + /^node(?:\.exe)?$/.test(executable.split(/[\\/]/).at(-1)) && + script !== undefined && + script.split(/[\\/]/).at(-1) === RUNNER_SCRIPT_NAME + ); +} + export function isVerifiedRunner(processInfo, desktopDir) { return ( - processInfo !== null && - processInfo.command.includes(RUNNER_SCRIPT_NAME) && - processInfo.cwd === desktopDir + processInfo !== null && isRunnerCommand(processInfo.command) && processInfo.cwd === desktopDir ); } diff --git a/apps/desktop/scripts/dev-runner-guards.test.mjs b/apps/desktop/scripts/dev-runner-guards.test.mjs index d28d815ae461..1477df260e01 100644 --- a/apps/desktop/scripts/dev-runner-guards.test.mjs +++ b/apps/desktop/scripts/dev-runner-guards.test.mjs @@ -50,6 +50,13 @@ describe("takeover decision", () => { }); assert.isFalse(isVerifiedRunner({ ...runner, cwd: null }, desktopDir)); }); + + it("matches the runner by its node entry script, not by a substring", () => { + const at = (command) => isVerifiedRunner({ command, cwd: desktopDir }, desktopDir); + assert.isTrue(at("/usr/local/bin/node --enable-source-maps scripts/dev-electron.mjs")); + assert.isFalse(at("tail -f scripts/dev-electron.mjs")); + assert.isFalse(at("node scripts/dev-electron.mjs.bak")); + }); }); describe("crash loop breaker", () => { diff --git a/apps/desktop/scripts/stop-dev-electron.mjs b/apps/desktop/scripts/stop-dev-electron.mjs index 06d429ba681f..f6f1ad5dec05 100644 --- a/apps/desktop/scripts/stop-dev-electron.mjs +++ b/apps/desktop/scripts/stop-dev-electron.mjs @@ -1,3 +1,4 @@ +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; @@ -5,6 +6,12 @@ import { releaseRunnerRecord, stopPreviousRunner } from "./dev-runner-control.mj const desktopDir = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. +if (NodeOS.platform() === "win32") { + console.error("[desktop-dev] stopping the runner is not supported on Windows; stop it manually"); + process.exit(1); +} + const { stoppedPid, refusedPid } = await stopPreviousRunner(desktopDir, 0); if (stoppedPid !== null) { releaseRunnerRecord(desktopDir, stoppedPid); From 6bcfe327fff8f55867a9d90be4bed1d425ef64d3 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 21 Oct 2026 06:13:31 -0400 Subject: [PATCH 3/6] fix(desktop): never let two runners share a checkout or stop one blindly Signed-off-by: Yordis Prieto --- apps/desktop/scripts/dev-electron.mjs | 18 ++-- apps/desktop/scripts/dev-runner-control.mjs | 90 +++++++++++++++---- apps/desktop/scripts/dev-runner-guards.mjs | 15 ++-- .../scripts/dev-runner-guards.test.mjs | 13 ++- .../scripts/electron-launcher.test.mjs | 3 +- apps/desktop/scripts/stop-dev-electron.mjs | 42 +++++---- 6 files changed, 129 insertions(+), 52 deletions(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 061450b3d995..31bec15432dd 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -8,11 +8,7 @@ import { resolveDevProtocolClient, resolveElectronLaunchCommand, } from "./electron-launcher.mjs"; -import { - claimRunnerRecord, - releaseRunnerRecord, - stopPreviousRunner, -} from "./dev-runner-control.mjs"; +import { acquireRunnerRecord, releaseRunnerRecord } from "./dev-runner-control.mjs"; import { createCrashLoopBreaker } from "./dev-runner-guards.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; @@ -64,11 +60,17 @@ const initialParentPid = process.ppid; // Two runners in one checkout rebuild the same launcher bundle concurrently and kill each // other's Electron processes, so a new runner replaces the previous one. -const { stoppedPid } = await stopPreviousRunner(desktopDir); -if (stoppedPid !== null) { +const claim = await acquireRunnerRecord(desktopDir); +if (!claim.claimed) { + console.error( + `[desktop-dev] runner ${claim.unverifiablePid} may still own this checkout but its working ` + + "directory could not be verified. Stop it, or delete .electron-runtime/runner.json, then retry.", + ); + process.exit(1); +} +for (const stoppedPid of claim.stoppedPids) { console.log(`[desktop-dev] stopped previous runner ${stoppedPid} for this checkout`); } -claimRunnerRecord(desktopDir); NodeChildProcess.execFileSync( process.execPath, diff --git a/apps/desktop/scripts/dev-runner-control.mjs b/apps/desktop/scripts/dev-runner-control.mjs index 68179e89f888..4a165c61541a 100644 --- a/apps/desktop/scripts/dev-runner-control.mjs +++ b/apps/desktop/scripts/dev-runner-control.mjs @@ -7,6 +7,7 @@ import { decideTakeover, parseRunnerRecord } from "./dev-runner-guards.mjs"; const stopGracePeriodMs = 4_000; const stopPollIntervalMs = 100; +const maxClaimAttempts = 5; // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. const hostPlatform = NodeOS.platform(); @@ -15,14 +16,19 @@ function runnerRecordPath(desktopDir) { return NodePath.join(desktopDir, ".electron-runtime", "runner.json"); } -function readRunnerRecord(desktopDir) { +function readRunnerRecordText(desktopDir) { try { - return parseRunnerRecord(NodeFS.readFileSync(runnerRecordPath(desktopDir), "utf8")); + return NodeFS.readFileSync(runnerRecordPath(desktopDir), "utf8"); } catch { return null; } } +function readRunnerRecord(desktopDir) { + const text = readRunnerRecordText(desktopDir); + return text === null ? null : parseRunnerRecord(text); +} + function readProcessCwd(pid) { if (hostPlatform === "linux") { try { @@ -68,33 +74,85 @@ async function waitForExit(pid, timeoutMs) { return !isAlive(pid); } -export async function stopPreviousRunner(desktopDir, selfPid = process.pid) { - if (hostPlatform === "win32") { - return { stoppedPid: null }; +function removeRunnerRecordIfUnchanged(desktopDir, text) { + if (readRunnerRecordText(desktopDir) === text) { + NodeFS.rmSync(runnerRecordPath(desktopDir), { force: true }); } +} + +async function terminate(pid) { + process.kill(pid, "SIGTERM"); + if (!(await waitForExit(pid, stopGracePeriodMs))) { + process.kill(pid, "SIGKILL"); + await waitForExit(pid, stopGracePeriodMs); + } +} - const record = readRunnerRecord(desktopDir); +async function clearRecordedRunner(desktopDir, selfPid) { + const text = readRunnerRecordText(desktopDir); + if (text === null) { + return { action: "none" }; + } + const record = parseRunnerRecord(text); const decision = decideTakeover({ record, selfPid, processInfo: record === null ? null : inspectProcess(record.pid), desktopDir, }); - if (decision.action !== "stop") { - return { stoppedPid: null, refusedPid: decision.action === "refuse" ? decision.pid : null }; + if (decision.action === "unverifiable") { + return decision; + } + if (decision.action === "stop") { + await terminate(decision.pid); } + removeRunnerRecordIfUnchanged(desktopDir, text); + return decision; +} - process.kill(decision.pid, "SIGTERM"); - if (!(await waitForExit(decision.pid, stopGracePeriodMs))) { - process.kill(decision.pid, "SIGKILL"); - await waitForExit(decision.pid, stopGracePeriodMs); +export async function stopRecordedRunner(desktopDir) { + if (hostPlatform === "win32") { + return { action: "unsupported" }; } - return { stoppedPid: decision.pid }; + return clearRecordedRunner(desktopDir, 0); } -export function claimRunnerRecord(desktopDir, pid = process.pid) { - NodeFS.mkdirSync(NodePath.dirname(runnerRecordPath(desktopDir)), { recursive: true }); - NodeFS.writeFileSync(runnerRecordPath(desktopDir), `${JSON.stringify({ pid })}\n`); +// The record is published with link() so it appears atomically with its full contents and two +// runners starting together cannot both believe they own the checkout. +export async function acquireRunnerRecord(desktopDir, pid = process.pid) { + const recordPath = runnerRecordPath(desktopDir); + const contents = `${JSON.stringify({ pid })}\n`; + NodeFS.mkdirSync(NodePath.dirname(recordPath), { recursive: true }); + if (hostPlatform === "win32") { + NodeFS.writeFileSync(recordPath, contents); + return { claimed: true, stoppedPids: [] }; + } + + const stagingPath = `${recordPath}.${pid}.tmp`; + NodeFS.writeFileSync(stagingPath, contents); + const stoppedPids = []; + try { + for (let attempt = 0; attempt < maxClaimAttempts; attempt += 1) { + try { + NodeFS.linkSync(stagingPath, recordPath); + return { claimed: true, stoppedPids }; + } catch (error) { + if (error?.code !== "EEXIST") { + throw error; + } + } + const decision = await clearRecordedRunner(desktopDir, pid); + if (decision.action === "unverifiable") { + return { claimed: false, unverifiablePid: decision.pid }; + } + if (decision.action === "stop") { + stoppedPids.push(decision.pid); + } + } + throw new Error(`another runner keeps claiming ${recordPath}`); + } finally { + NodeFS.rmSync(stagingPath, { force: true }); + } } export function releaseRunnerRecord(desktopDir, pid = process.pid) { diff --git a/apps/desktop/scripts/dev-runner-guards.mjs b/apps/desktop/scripts/dev-runner-guards.mjs index 207f96edd7ba..feec05dc04ec 100644 --- a/apps/desktop/scripts/dev-runner-guards.mjs +++ b/apps/desktop/scripts/dev-runner-guards.mjs @@ -27,15 +27,20 @@ export function isVerifiedRunner(processInfo, desktopDir) { ); } -// `processInfo` is null when the pid is not alive. A live pid that fails verification -// is never signaled because the pid may have been reused by an unrelated process. +// `processInfo` is null when the pid is not alive. Only a verified runner is signaled: a pid +// running something else was reused, so its record is stale, while a runner whose working +// directory cannot be read may belong to this checkout and must be neither signaled nor replaced. export function decideTakeover({ record, selfPid, processInfo, desktopDir }) { if (record === null || record.pid === selfPid || processInfo === null) { return { action: "none" }; } - return isVerifiedRunner(processInfo, desktopDir) - ? { action: "stop", pid: record.pid } - : { action: "refuse", pid: record.pid }; + if (isVerifiedRunner(processInfo, desktopDir)) { + return { action: "stop", pid: record.pid }; + } + if (isRunnerCommand(processInfo.command) && processInfo.cwd === null) { + return { action: "unverifiable", pid: record.pid }; + } + return { action: "stale", pid: record.pid }; } export function createCrashLoopBreaker({ maxAbnormalExits = 3, windowMs = 10_000 } = {}) { diff --git a/apps/desktop/scripts/dev-runner-guards.test.mjs b/apps/desktop/scripts/dev-runner-guards.test.mjs index 1477df260e01..3e75a05f4e93 100644 --- a/apps/desktop/scripts/dev-runner-guards.test.mjs +++ b/apps/desktop/scripts/dev-runner-guards.test.mjs @@ -39,13 +39,20 @@ describe("takeover decision", () => { assert.deepEqual(decide({ selfPid: 100 }), { action: "none" }); }); - it("refuses a reused pid that is not this checkout's runner", () => { + it("treats a pid reused by another process as a stale record", () => { assert.deepEqual(decide({ processInfo: { command: "sleep 30", cwd: desktopDir } }), { - action: "refuse", + action: "stale", pid: 100, }); assert.deepEqual(decide({ processInfo: { ...runner, cwd: "/work/b/apps/desktop" } }), { - action: "refuse", + action: "stale", + pid: 100, + }); + }); + + it("will not stop or replace a runner whose working directory is unknown", () => { + assert.deepEqual(decide({ processInfo: { ...runner, cwd: null } }), { + action: "unverifiable", pid: 100, }); assert.isFalse(isVerifiedRunner({ ...runner, cwd: null }, desktopDir)); diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 6c5ef77a8164..dd88bf2e47b5 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -137,8 +137,7 @@ async function startupCleanupPattern(desktopDir) { NodeOS: { platform: () => "linux" }, NodePath, desktopDir, - stopPreviousRunner: async () => ({ stoppedPid: null }), - claimRunnerRecord: () => undefined, + acquireRunnerRecord: async () => ({ claimed: true, stoppedPids: [] }), releaseRunnerRecord: () => undefined, createCrashLoopBreaker: () => ({ recordAbnormalExit: () => ({ tripped: false }), diff --git a/apps/desktop/scripts/stop-dev-electron.mjs b/apps/desktop/scripts/stop-dev-electron.mjs index f6f1ad5dec05..d6603a12a79a 100644 --- a/apps/desktop/scripts/stop-dev-electron.mjs +++ b/apps/desktop/scripts/stop-dev-electron.mjs @@ -1,25 +1,31 @@ -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; -import { releaseRunnerRecord, stopPreviousRunner } from "./dev-runner-control.mjs"; +import { stopRecordedRunner } from "./dev-runner-control.mjs"; const desktopDir = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); -// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. -if (NodeOS.platform() === "win32") { - console.error("[desktop-dev] stopping the runner is not supported on Windows; stop it manually"); - process.exit(1); -} - -const { stoppedPid, refusedPid } = await stopPreviousRunner(desktopDir, 0); -if (stoppedPid !== null) { - releaseRunnerRecord(desktopDir, stoppedPid); - console.log(`[desktop-dev] stopped runner ${stoppedPid} for this checkout`); -} else if (refusedPid) { - console.log( - `[desktop-dev] runner.json names pid ${refusedPid}, which is not a runner for this checkout; left it alone`, - ); -} else { - console.log("[desktop-dev] no runner is running for this checkout"); +const result = await stopRecordedRunner(desktopDir); +switch (result.action) { + case "stop": + console.log(`[desktop-dev] stopped runner ${result.pid} for this checkout`); + break; + case "stale": + console.log(`[desktop-dev] removed a stale runner.json for pid ${result.pid}`); + break; + case "none": + console.log("[desktop-dev] no runner is running for this checkout"); + break; + case "unverifiable": + console.error( + `[desktop-dev] runner ${result.pid} may own this checkout but its working directory could not be verified; stop it manually`, + ); + process.exitCode = 1; + break; + case "unsupported": + console.error( + "[desktop-dev] stopping the runner is not supported on Windows; stop it manually", + ); + process.exitCode = 1; + break; } From 35c014870ef9f3d5735e49866c10cc0c9ee2df5d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 21 Oct 2026 06:45:36 -0400 Subject: [PATCH 4/6] fix(desktop): a runner exiting during takeover no longer blocks the new one Signed-off-by: Yordis Prieto --- apps/desktop/scripts/dev-runner-control.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/desktop/scripts/dev-runner-control.mjs b/apps/desktop/scripts/dev-runner-control.mjs index 4a165c61541a..2a92d09ed4e9 100644 --- a/apps/desktop/scripts/dev-runner-control.mjs +++ b/apps/desktop/scripts/dev-runner-control.mjs @@ -80,10 +80,21 @@ function removeRunnerRecordIfUnchanged(desktopDir, text) { } } +// The runner can exit on its own between inspection and each signal. +function signalIfAlive(pid, signal) { + try { + process.kill(pid, signal); + } catch (error) { + if (error?.code !== "ESRCH") { + throw error; + } + } +} + async function terminate(pid) { - process.kill(pid, "SIGTERM"); + signalIfAlive(pid, "SIGTERM"); if (!(await waitForExit(pid, stopGracePeriodMs))) { - process.kill(pid, "SIGKILL"); + signalIfAlive(pid, "SIGKILL"); await waitForExit(pid, stopGracePeriodMs); } } From 2b03baffaab927d7a32151a6a398adc582b1764b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 21 Oct 2026 06:56:29 -0400 Subject: [PATCH 5/6] fix(desktop): an exiting runner no longer kills its replacement's app Signed-off-by: Yordis Prieto --- apps/desktop/scripts/dev-electron.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 31bec15432dd..7b587c6b0b55 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -291,13 +291,15 @@ async function shutdown(exitCode) { } clearInterval(orphanCheck); - releaseRunnerRecord(desktopDir); await stopApp(); killChildTree("TERM"); await new Promise((resolve) => { setTimeout(resolve, childTreeGracePeriodMs); }); killChildTree("KILL"); + // Released only after teardown, because the teardown kills every Electron tagged with this + // checkout and would take down a replacement runner's app. + releaseRunnerRecord(desktopDir); process.exit(exitCode); } From 33be29613a5935aa71219063d00745c98328b6a4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 21 Oct 2026 06:57:03 -0400 Subject: [PATCH 6/6] fix(desktop): stop and reset fail when the runner survives SIGKILL Signed-off-by: Yordis Prieto --- apps/desktop/scripts/dev-electron.mjs | 8 ++++++-- apps/desktop/scripts/dev-runner-control.mjs | 11 ++++++----- apps/desktop/scripts/stop-dev-electron.mjs | 6 ++++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 7b587c6b0b55..8938763a3730 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -62,9 +62,13 @@ const initialParentPid = process.ppid; // other's Electron processes, so a new runner replaces the previous one. const claim = await acquireRunnerRecord(desktopDir); if (!claim.claimed) { + const reason = + claim.blocker.action === "survived" + ? "did not exit after SIGKILL" + : "may still own this checkout but its working directory could not be verified"; console.error( - `[desktop-dev] runner ${claim.unverifiablePid} may still own this checkout but its working ` + - "directory could not be verified. Stop it, or delete .electron-runtime/runner.json, then retry.", + `[desktop-dev] runner ${claim.blocker.pid} ${reason}. ` + + "Stop it, or delete .electron-runtime/runner.json, then retry.", ); process.exit(1); } diff --git a/apps/desktop/scripts/dev-runner-control.mjs b/apps/desktop/scripts/dev-runner-control.mjs index 2a92d09ed4e9..8474d0804fde 100644 --- a/apps/desktop/scripts/dev-runner-control.mjs +++ b/apps/desktop/scripts/dev-runner-control.mjs @@ -95,8 +95,9 @@ async function terminate(pid) { signalIfAlive(pid, "SIGTERM"); if (!(await waitForExit(pid, stopGracePeriodMs))) { signalIfAlive(pid, "SIGKILL"); - await waitForExit(pid, stopGracePeriodMs); + return waitForExit(pid, stopGracePeriodMs); } + return true; } async function clearRecordedRunner(desktopDir, selfPid) { @@ -114,8 +115,8 @@ async function clearRecordedRunner(desktopDir, selfPid) { if (decision.action === "unverifiable") { return decision; } - if (decision.action === "stop") { - await terminate(decision.pid); + if (decision.action === "stop" && !(await terminate(decision.pid))) { + return { action: "survived", pid: decision.pid }; } removeRunnerRecordIfUnchanged(desktopDir, text); return decision; @@ -153,8 +154,8 @@ export async function acquireRunnerRecord(desktopDir, pid = process.pid) { } } const decision = await clearRecordedRunner(desktopDir, pid); - if (decision.action === "unverifiable") { - return { claimed: false, unverifiablePid: decision.pid }; + if (decision.action === "unverifiable" || decision.action === "survived") { + return { claimed: false, blocker: decision }; } if (decision.action === "stop") { stoppedPids.push(decision.pid); diff --git a/apps/desktop/scripts/stop-dev-electron.mjs b/apps/desktop/scripts/stop-dev-electron.mjs index d6603a12a79a..9f27552597c3 100644 --- a/apps/desktop/scripts/stop-dev-electron.mjs +++ b/apps/desktop/scripts/stop-dev-electron.mjs @@ -22,6 +22,12 @@ switch (result.action) { ); process.exitCode = 1; break; + case "survived": + console.error( + `[desktop-dev] runner ${result.pid} did not exit after SIGKILL; stop it manually`, + ); + process.exitCode = 1; + break; case "unsupported": console.error( "[desktop-dev] stopping the runner is not supported on Windows; stop it manually",