From 07aaf37fd4e0c34dbd9ec85c63858d19fa7eb447 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Mar 2026 22:30:55 -0800 Subject: [PATCH] Prevent desktop dev Electron process leaks during restarts - replace `electronmon` with explicit Electron spawn/stop lifecycle management - debounce file-watch restarts for main/preload/server build outputs - add stale-process cleanup and forced child-tree shutdown fallback --- apps/desktop/scripts/dev-electron.mjs | 192 ++++++++++++++++++++++---- 1 file changed, 168 insertions(+), 24 deletions(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 28970a2cc2030dc5bd3e007e52db98cf97ee2d85..5696095760f096c4cedc897d5dba19a13646abb4 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -1,43 +1,179 @@ -import { spawnSync } from "node:child_process"; - -import electronmon from "electronmon"; +import { spawn, spawnSync } from "node:child_process"; +import { watch } from "node:fs"; +import { join } from "node:path"; import waitOn from "wait-on"; import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); const devServerUrl = `http://localhost:${port}`; +const requiredFiles = ["dist-electron/main.js", "dist-electron/preload.js", "../server/dist/index.mjs"]; +const watchedDirectories = [ + { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) }, + { directory: "../server/dist", files: new Set(["index.mjs"]) }, +]; +const forcedShutdownTimeoutMs = 1_500; +const restartDebounceMs = 120; +const workspaceDir = join(desktopDir, "..", ".."); await waitOn({ - resources: [ - `tcp:${port}`, - "file:dist-electron/main.js", - "file:dist-electron/preload.js", - "file:../server/dist/index.mjs", - ], + resources: [`tcp:${port}`, ...requiredFiles.map((filePath) => `file:${filePath}`)], }); const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const monitor = await electronmon({ - cwd: desktopDir, - args: ["dist-electron/main.js"], - env: { - ...childEnv, - VITE_DEV_SERVER_URL: devServerUrl, - }, - electronPath: resolveElectronPath(), -}); - let shuttingDown = false; +let restartTimer = null; +let currentApp = null; +let restartQueue = Promise.resolve(); +const expectedExits = new WeakSet(); +const watchers = []; + +function killChildTreeByPid(pid, signal) { + if (process.platform === "win32" || typeof pid !== "number") { + return; + } + + spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" }); +} + +function cleanupStaleDevApps() { + if (process.platform === "win32") { + return; + } + + const patterns = [ + `${workspaceDir}/node_modules/.bun/electronmon@.+/node_modules/electronmon/src/hook.js dist-electron/main.js`, + ]; + + for (const pattern of patterns) { + spawnSync("pkill", ["-f", pattern], { stdio: "ignore" }); + } + + spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { stdio: "ignore" }); +} + +function startApp() { + if (shuttingDown || currentApp !== null) { + return; + } + + const app = spawn( + resolveElectronPath(), + [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], + { + cwd: desktopDir, + env: { + ...childEnv, + VITE_DEV_SERVER_URL: devServerUrl, + }, + stdio: "inherit", + }, + ); + + currentApp = app; + + app.once("error", () => { + if (currentApp === app) { + currentApp = null; + } + + if (!shuttingDown) { + scheduleRestart(); + } + }); + + app.once("exit", () => { + if (currentApp === app) { + currentApp = null; + } + + if (!shuttingDown && !expectedExits.has(app)) { + scheduleRestart(); + } + }); +} + +async function stopApp() { + const app = currentApp; + if (!app) { + return; + } + + currentApp = null; + expectedExits.add(app); + + await new Promise((resolve) => { + let settled = false; + + const finish = () => { + if (settled) { + return; + } + + settled = true; + resolve(); + }; + + app.once("exit", finish); + app.kill("SIGTERM"); + killChildTreeByPid(app.pid, "TERM"); + + setTimeout(() => { + if (settled) { + return; + } + + app.kill("SIGKILL"); + killChildTreeByPid(app.pid, "KILL"); + finish(); + }, forcedShutdownTimeoutMs).unref(); + }); +} + +function scheduleRestart() { + if (shuttingDown) { + return; + } + + if (restartTimer) { + clearTimeout(restartTimer); + } + + restartTimer = setTimeout(() => { + restartTimer = null; + restartQueue = restartQueue + .catch(() => undefined) + .then(async () => { + await stopApp(); + if (!shuttingDown) { + startApp(); + } + }); + }, restartDebounceMs); +} + +function startWatchers() { + for (const { directory, files } of watchedDirectories) { + const watcher = watch(join(desktopDir, directory), { persistent: true }, (_eventType, filename) => { + if (typeof filename !== "string" || !files.has(filename)) { + return; + } + + scheduleRestart(); + }); + + watchers.push(watcher); + } +} function killChildTree(signal) { if (process.platform === "win32") { return; } - // electronmon may leave Electron orphaned when parent shutdown races; kill direct children as fallback. + // Kill direct children as a final fallback in case normal shutdown leaves stragglers. spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" }); } @@ -45,12 +181,16 @@ async function shutdown(exitCode) { if (shuttingDown) return; shuttingDown = true; - try { - await Promise.race([monitor.destroy(), new Promise((resolve) => setTimeout(resolve, 1_500))]); - } catch { - // Best effort only; fallback process kill below handles stubborn children. + if (restartTimer) { + clearTimeout(restartTimer); + restartTimer = null; } + for (const watcher of watchers) { + watcher.close(); + } + + await stopApp(); killChildTree("TERM"); setTimeout(() => { killChildTree("KILL"); @@ -59,6 +199,10 @@ async function shutdown(exitCode) { process.exit(exitCode); } +startWatchers(); +cleanupStaleDevApps(); +startApp(); + process.once("SIGINT", () => { void shutdown(130); }); -- 2.54.0