From 840e2127cf2c8e5ba7d69ad8006e65af1f6f0943 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 24 Oct 2026 23:12:42 -0400 Subject: [PATCH 1/3] fix(desktop): dev runner reclaims orphaned backends that hold the T3 home Signed-off-by: Yordis Prieto --- apps/desktop/scripts/dev-electron.mjs | 36 +++++++++++++++---- apps/desktop/scripts/dev-runner-guards.mjs | 17 +++++++++ .../scripts/dev-runner-guards.test.mjs | 29 +++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 8938763a3730..5efb226df4d3 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -9,7 +9,7 @@ import { resolveElectronLaunchCommand, } from "./electron-launcher.mjs"; import { acquireRunnerRecord, releaseRunnerRecord } from "./dev-runner-control.mjs"; -import { createCrashLoopBreaker } from "./dev-runner-guards.mjs"; +import { createCrashLoopBreaker, devAppPattern, devBackendPattern } from "./dev-runner-guards.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); @@ -49,6 +49,8 @@ const watchedDirectories = [ { directory: "../server/dist", files: new Set(["bin.mjs"]) }, ]; const forcedShutdownTimeoutMs = 1_500; +const backendShutdownTimeoutMs = 4_000; +const backendPollIntervalMs = 100; const restartDebounceMs = 120; const childTreeGracePeriodMs = 1_200; const orphanCheckIntervalMs = 1_000; @@ -124,16 +126,34 @@ function cleanupStaleDevApps() { return; } - const escapedDesktopDir = desktopDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - NodeChildProcess.spawnSync( - "pkill", - ["-f", "--", `(^| )--t3code-dev-root=${escapedDesktopDir}( |$)`], - { + NodeChildProcess.spawnSync("pkill", ["-f", "--", devAppPattern(desktopDir)], { + stdio: "ignore", + }); +} + +// pkill and pgrep exit 0 only when the pattern matched a process. +function matchDevBackends(command, args) { + return ( + NodeChildProcess.spawnSync(command, [...args, "-f", "--", devBackendPattern(desktopDir)], { stdio: "ignore", - }, + }).status === 0 ); } +async function stopStaleDevBackends() { + if (hostPlatform === "win32" || !matchDevBackends("pkill", ["-TERM"])) { + return; + } + + for (let waited = 0; waited < backendShutdownTimeoutMs; waited += backendPollIntervalMs) { + await new Promise((resolve) => setTimeout(resolve, backendPollIntervalMs)); + if (!matchDevBackends("pgrep", [])) { + return; + } + } + matchDevBackends("pkill", ["-KILL"]); +} + function startApp() { if (shuttingDown || currentApp !== null) { return; @@ -228,6 +248,7 @@ async function stopApp() { finish(); }, forcedShutdownTimeoutMs).unref(); }); + await stopStaleDevBackends(); } function scheduleRestart() { @@ -316,6 +337,7 @@ const orphanCheck = setInterval(() => { startWatchers(); cleanupStaleDevApps(); +await stopStaleDevBackends(); startApp(); process.once("SIGINT", () => { diff --git a/apps/desktop/scripts/dev-runner-guards.mjs b/apps/desktop/scripts/dev-runner-guards.mjs index feec05dc04ec..5b9ebc814eab 100644 --- a/apps/desktop/scripts/dev-runner-guards.mjs +++ b/apps/desktop/scripts/dev-runner-guards.mjs @@ -1,5 +1,22 @@ +import * as NodePath from "node:path"; + const RUNNER_SCRIPT_NAME = "dev-electron.mjs"; +const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// `pkill -f` patterns for the Electron apps a runner launches from one checkout. +export function devAppPattern(desktopDir) { + return `(^| )--t3code-dev-root=${escapeRegExp(desktopDir)}( |$)`; +} + +// The backend Electron spawns carries no dev-root tag and is the runner's grandchild, so neither +// the tag nor `pkill -P` reaches it once its Electron parent is gone. A hung one keeps the T3 +// home's ownership lock and every later start fails with ServerAlreadyRunningError. +export function devBackendPattern(desktopDir) { + const serverEntry = NodePath.join(NodePath.dirname(desktopDir), "server", "dist", "bin.mjs"); + return `(^| )${escapeRegExp(serverEntry)} --bootstrap-fd( |$)`; +} + export function parseRunnerRecord(text) { let parsed; try { diff --git a/apps/desktop/scripts/dev-runner-guards.test.mjs b/apps/desktop/scripts/dev-runner-guards.test.mjs index 3e75a05f4e93..d8fc90d2f3e4 100644 --- a/apps/desktop/scripts/dev-runner-guards.test.mjs +++ b/apps/desktop/scripts/dev-runner-guards.test.mjs @@ -3,6 +3,8 @@ import { assert, describe, it } from "vite-plus/test"; import { createCrashLoopBreaker, decideTakeover, + devAppPattern, + devBackendPattern, isVerifiedRunner, parseRunnerRecord, } from "./dev-runner-guards.mjs"; @@ -92,3 +94,30 @@ describe("crash loop breaker", () => { assert.isTrue(breaker.recordAbnormalExit(4_000).tripped); }); }); + +describe("dev process patterns", () => { + const matches = (pattern, command) => new RegExp(pattern).test(command); + const electron = + "/work/a/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron"; + + it("matches the orphaned backend of this checkout only", () => { + const pattern = devBackendPattern(desktopDir); + assert.isTrue( + matches(pattern, `${electron} /work/a/apps/server/dist/bin.mjs --bootstrap-fd 3`), + ); + assert.isFalse( + matches(pattern, `${electron} /work/b/apps/server/dist/bin.mjs --bootstrap-fd 3`), + ); + assert.isFalse(matches(pattern, "node /work/a/apps/server/dist/bin.mjs serve")); + }); + + it("escapes checkout paths instead of treating them as regex", () => { + const pattern = devAppPattern("/work/a+b (1)/apps/desktop"); + assert.isTrue( + matches(pattern, "Electron --t3code-dev-root=/work/a+b (1)/apps/desktop main.cjs"), + ); + assert.isFalse( + matches(pattern, "Electron --t3code-dev-root=/work/aab 1/apps/desktop main.cjs"), + ); + }); +}); From 6a81febe13e7169ea5030f24d5695115c5d1628b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 24 Oct 2026 23:21:31 -0400 Subject: [PATCH 2/3] fix(desktop): reclaim the backend after Electron crashes too Signed-off-by: Yordis Prieto --- apps/desktop/scripts/dev-electron.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 5efb226df4d3..01ff2f984526 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -214,6 +214,8 @@ function restartAfterFailure(app) { async function stopApp() { const app = currentApp; if (!app) { + // A crashed Electron is already gone, but its backend may still hold the T3 home. + await stopStaleDevBackends(); return; } From 09db31405b039ac1fefd2f1ff5535c55323e561f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 24 Oct 2026 23:36:09 -0400 Subject: [PATCH 3/3] test(desktop): keep the dev cleanup harness in step with the runner imports Signed-off-by: Yordis Prieto --- apps/desktop/scripts/dev-runner-guards.test.mjs | 11 ----------- apps/desktop/scripts/electron-launcher.test.mjs | 8 +++++++- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/apps/desktop/scripts/dev-runner-guards.test.mjs b/apps/desktop/scripts/dev-runner-guards.test.mjs index d8fc90d2f3e4..2695625869e6 100644 --- a/apps/desktop/scripts/dev-runner-guards.test.mjs +++ b/apps/desktop/scripts/dev-runner-guards.test.mjs @@ -3,7 +3,6 @@ import { assert, describe, it } from "vite-plus/test"; import { createCrashLoopBreaker, decideTakeover, - devAppPattern, devBackendPattern, isVerifiedRunner, parseRunnerRecord, @@ -110,14 +109,4 @@ describe("dev process patterns", () => { ); assert.isFalse(matches(pattern, "node /work/a/apps/server/dist/bin.mjs serve")); }); - - it("escapes checkout paths instead of treating them as regex", () => { - const pattern = devAppPattern("/work/a+b (1)/apps/desktop"); - assert.isTrue( - matches(pattern, "Electron --t3code-dev-root=/work/a+b (1)/apps/desktop main.cjs"), - ); - assert.isFalse( - matches(pattern, "Electron --t3code-dev-root=/work/aab 1/apps/desktop main.cjs"), - ); - }); }); diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index dd88bf2e47b5..3bb48f15b55a 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -7,6 +7,8 @@ import * as NodeVM from "node:vm"; import { assert, describe, it } from "vite-plus/test"; +import { devAppPattern, devBackendPattern } from "./dev-runner-guards.mjs"; + import { makeDevelopmentEnvironmentScript, makeDevelopmentLauncherScript, @@ -129,7 +131,9 @@ async function startupCleanupPattern(desktopDir) { NodeChildProcess: { execFileSync: () => undefined, spawnSync: (command, args) => { - if (command === "pkill" && args.includes("-f")) pattern = args.at(-1); + // The dev-root cleanup signals first; later backend cleanup must find nothing to stop. + if (command === "pkill" && args.includes("-f")) pattern ??= args.at(-1); + return { status: 1 }; }, spawn: () => new NodeEvents.EventEmitter(), }, @@ -139,6 +143,8 @@ async function startupCleanupPattern(desktopDir) { desktopDir, acquireRunnerRecord: async () => ({ claimed: true, stoppedPids: [] }), releaseRunnerRecord: () => undefined, + devAppPattern, + devBackendPattern, createCrashLoopBreaker: () => ({ recordAbnormalExit: () => ({ tripped: false }), rearm: () => undefined,