- 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
Unified · Split
| @@ -1,43 +1,179 @@ | |||
| 1 | import { spawnSync } from "node:child_process"; | 1 | import { spawn, spawnSync } from "node:child_process"; |
| 2 | 2 | import { watch } from "node:fs"; | |
| 3 | import electronmon from "electronmon"; | 3 | import { join } from "node:path"; |
| 4 | import waitOn from "wait-on"; | 4 | import waitOn from "wait-on"; |
| 5 | 5 | ||
| 6 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; | 6 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; |
| 7 | 7 | ||
| 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); | 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); |
| 9 | const devServerUrl = `http://localhost:${port}`; | 9 | const devServerUrl = `http://localhost:${port}`; |
| 10 | const requiredFiles = ["dist-electron/main.js", "dist-electron/preload.js", "../server/dist/index.mjs"]; | ||
| 11 | const watchedDirectories = [ | ||
| 12 | { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) }, | ||
| 13 | { directory: "../server/dist", files: new Set(["index.mjs"]) }, | ||
| 14 | ]; | ||
| 15 | const forcedShutdownTimeoutMs = 1_500; | ||
| 16 | const restartDebounceMs = 120; | ||
| 17 | const workspaceDir = join(desktopDir, "..", ".."); | ||
| 10 | 18 | ||
| 11 | await waitOn({ | 19 | await waitOn({ |
| 12 | resources: [ | 20 | resources: [`tcp:${port}`, ...requiredFiles.map((filePath) => `file:${filePath}`)], |
| 13 | `tcp:${port}`, | ||
| 14 | "file:dist-electron/main.js", | ||
| 15 | "file:dist-electron/preload.js", | ||
| 16 | "file:../server/dist/index.mjs", | ||
| 17 | ], | ||
| 18 | }); | 21 | }); |
| 19 | 22 | ||
| 20 | const childEnv = { ...process.env }; | 23 | const childEnv = { ...process.env }; |
| 21 | delete childEnv.ELECTRON_RUN_AS_NODE; | 24 | delete childEnv.ELECTRON_RUN_AS_NODE; |
| 22 | 25 | ||
| 23 | const monitor = await electronmon({ | ||
| 24 | cwd: desktopDir, | ||
| 25 | args: ["dist-electron/main.js"], | ||
| 26 | env: { | ||
| 27 | ...childEnv, | ||
| 28 | VITE_DEV_SERVER_URL: devServerUrl, | ||
| 29 | }, | ||
| 30 | electronPath: resolveElectronPath(), | ||
| 31 | }); | ||
| 32 | |||
| 33 | let shuttingDown = false; | 26 | let shuttingDown = false; |
| 27 | let restartTimer = null; | ||
| 28 | let currentApp = null; | ||
| 29 | let restartQueue = Promise.resolve(); | ||
| 30 | const expectedExits = new WeakSet(); | ||
| 31 | const watchers = []; | ||
| 32 | |||
| 33 | function killChildTreeByPid(pid, signal) { | ||
| 34 | if (process.platform === "win32" || typeof pid !== "number") { | ||
| 35 | return; | ||
| 36 | } | ||
| 37 | |||
| 38 | spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" }); | ||
| 39 | } | ||
| 40 | |||
| 41 | function cleanupStaleDevApps() { | ||
| 42 | if (process.platform === "win32") { | ||
| 43 | return; | ||
| 44 | } | ||
| 45 | |||
| 46 | const patterns = [ | ||
| 47 | `${workspaceDir}/node_modules/.bun/electronmon@.+/node_modules/electronmon/src/hook.js dist-electron/main.js`, | ||
| 48 | ]; | ||
| 49 | |||
| 50 | for (const pattern of patterns) { | ||
| 51 | spawnSync("pkill", ["-f", pattern], { stdio: "ignore" }); | ||
| 52 | } | ||
| 53 | |||
| 54 | spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { stdio: "ignore" }); | ||
| 55 | } | ||
| 56 | |||
| 57 | function startApp() { | ||
| 58 | if (shuttingDown || currentApp !== null) { | ||
| 59 | return; | ||
| 60 | } | ||
| 61 | |||
| 62 | const app = spawn( | ||
| 63 | resolveElectronPath(), | ||
| 64 | [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], | ||
| 65 | { | ||
| 66 | cwd: desktopDir, | ||
| 67 | env: { | ||
| 68 | ...childEnv, | ||
| 69 | VITE_DEV_SERVER_URL: devServerUrl, | ||
| 70 | }, | ||
| 71 | stdio: "inherit", | ||
| 72 | }, | ||
| 73 | ); | ||
| 74 | |||
| 75 | currentApp = app; | ||
| 76 | |||
| 77 | app.once("error", () => { | ||
| 78 | if (currentApp === app) { | ||
| 79 | currentApp = null; | ||
| 80 | } | ||
| 81 | |||
| 82 | if (!shuttingDown) { | ||
| 83 | scheduleRestart(); | ||
| 84 | } | ||
| 85 | }); | ||
| 86 | |||
| 87 | app.once("exit", () => { | ||
| 88 | if (currentApp === app) { | ||
| 89 | currentApp = null; | ||
| 90 | } | ||
| 91 | |||
| 92 | if (!shuttingDown && !expectedExits.has(app)) { | ||
| 93 | scheduleRestart(); | ||
| 94 | } | ||
| 95 | }); | ||
| 96 | } | ||
| 97 | |||
| 98 | async function stopApp() { | ||
| 99 | const app = currentApp; | ||
| 100 | if (!app) { | ||
| 101 | return; | ||
| 102 | } | ||
| 103 | |||
| 104 | currentApp = null; | ||
| 105 | expectedExits.add(app); | ||
| 106 | |||
| 107 | await new Promise((resolve) => { | ||
| 108 | let settled = false; | ||
| 109 | |||
| 110 | const finish = () => { | ||
| 111 | if (settled) { | ||
| 112 | return; | ||
| 113 | } | ||
| 114 | |||
| 115 | settled = true; | ||
| 116 | resolve(); | ||
| 117 | }; | ||
| 118 | |||
| 119 | app.once("exit", finish); | ||
| 120 | app.kill("SIGTERM"); | ||
| 121 | killChildTreeByPid(app.pid, "TERM"); | ||
| 122 | |||
| 123 | setTimeout(() => { | ||
| 124 | if (settled) { | ||
| 125 | return; | ||
| 126 | } | ||
| 127 | |||
| 128 | app.kill("SIGKILL"); | ||
| 129 | killChildTreeByPid(app.pid, "KILL"); | ||
| 130 | finish(); | ||
| 131 | }, forcedShutdownTimeoutMs).unref(); | ||
| 132 | }); | ||
| 133 | } | ||
| 134 | |||
| 135 | function scheduleRestart() { | ||
| 136 | if (shuttingDown) { | ||
| 137 | return; | ||
| 138 | } | ||
| 139 | |||
| 140 | if (restartTimer) { | ||
| 141 | clearTimeout(restartTimer); | ||
| 142 | } | ||
| 143 | |||
| 144 | restartTimer = setTimeout(() => { | ||
| 145 | restartTimer = null; | ||
| 146 | restartQueue = restartQueue | ||
| 147 | .catch(() => undefined) | ||
| 148 | .then(async () => { | ||
| 149 | await stopApp(); | ||
| 150 | if (!shuttingDown) { | ||
| 151 | startApp(); | ||
| 152 | } | ||
| 153 | }); | ||
| 154 | }, restartDebounceMs); | ||
| 155 | } | ||
| 156 | |||
| 157 | function startWatchers() { | ||
| 158 | for (const { directory, files } of watchedDirectories) { | ||
| 159 | const watcher = watch(join(desktopDir, directory), { persistent: true }, (_eventType, filename) => { | ||
| 160 | if (typeof filename !== "string" || !files.has(filename)) { | ||
| 161 | return; | ||
| 162 | } | ||
| 163 | |||
| 164 | scheduleRestart(); | ||
| 165 | }); | ||
| 166 | |||
| 167 | watchers.push(watcher); | ||
| 168 | } | ||
| 169 | } | ||
| 34 | 170 | ||
| 35 | function killChildTree(signal) { | 171 | function killChildTree(signal) { |
| 36 | if (process.platform === "win32") { | 172 | if (process.platform === "win32") { |
| 37 | return; | 173 | return; |
| 38 | } | 174 | } |
| 39 | 175 | ||
| 40 | // electronmon may leave Electron orphaned when parent shutdown races; kill direct children as fallback. | 176 | // Kill direct children as a final fallback in case normal shutdown leaves stragglers. |
| 41 | spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" }); | 177 | spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" }); |
| 42 | } | 178 | } |
| 43 | 179 | ||
| @@ -45,12 +181,16 @@ async function shutdown(exitCode) { · 1 unmodified line | |||
| 45 | if (shuttingDown) return; | 181 | if (shuttingDown) return; |
| 46 | shuttingDown = true; | 182 | shuttingDown = true; |
| 47 | 183 | ||
| 48 | try { | 184 | if (restartTimer) { |
| 49 | await Promise.race([monitor.destroy(), new Promise((resolve) => setTimeout(resolve, 1_500))]); | 185 | clearTimeout(restartTimer); |
| 50 | } catch { | 186 | restartTimer = null; |
| 51 | // Best effort only; fallback process kill below handles stubborn children. | ||
| 52 | } | 187 | } |
| 53 | 188 | ||
| 189 | for (const watcher of watchers) { | ||
| 190 | watcher.close(); | ||
| 191 | } | ||
| 192 | |||
| 193 | await stopApp(); | ||
| 54 | killChildTree("TERM"); | 194 | killChildTree("TERM"); |
| 55 | setTimeout(() => { | 195 | setTimeout(() => { |
| 56 | killChildTree("KILL"); | 196 | killChildTree("KILL"); |
| @@ -59,6 +199,10 @@ async function shutdown(exitCode) { · 2 unmodified lines | |||
| 59 | process.exit(exitCode); | 199 | process.exit(exitCode); |
| 60 | } | 200 | } |
| 61 | 201 | ||
| 202 | startWatchers(); | ||
| 203 | cleanupStaleDevApps(); | ||
| 204 | startApp(); | ||
| 205 | |||
| 62 | process.once("SIGINT", () => { | 206 | process.once("SIGINT", () => { |
| 63 | void shutdown(130); | 207 | void shutdown(130); |
| 64 | }); | 208 | }); |