| ... | @@ -1,43 +1,179 @@ | ... | @@ -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) { | ... | @@ -45,12 +181,16 @@ async function shutdown(exitCode) { |
| 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) { | ... | @@ -59,6 +199,10 @@ async function shutdown(exitCode) { |
| 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 | }); |