diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 8938763a3730..01ff2f984526 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; @@ -194,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; } @@ -228,6 +250,7 @@ async function stopApp() { finish(); }, forcedShutdownTimeoutMs).unref(); }); + await stopStaleDevBackends(); } function scheduleRestart() { @@ -316,6 +339,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..2695625869e6 100644 --- a/apps/desktop/scripts/dev-runner-guards.test.mjs +++ b/apps/desktop/scripts/dev-runner-guards.test.mjs @@ -3,6 +3,7 @@ import { assert, describe, it } from "vite-plus/test"; import { createCrashLoopBreaker, decideTakeover, + devBackendPattern, isVerifiedRunner, parseRunnerRecord, } from "./dev-runner-guards.mjs"; @@ -92,3 +93,20 @@ 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")); + }); +}); 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,