authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 22:30:55-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-04 09:58:22-08:00
log07aaf37fd4e0c34dbd9ec85c63858d19fa7eb447
tree7e55113377a084696a160ac68c25235087e6996f
parent65a808808f85a4e15e341c1a20dc574f20f9570b

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

1 files changed, 168 insertions(+), 24 deletions(-)

apps/desktop/scripts/dev-electron.mjs+168-24
......@@ -1,43 +1,179 @@
1import { spawnSync } from "node:child_process";
2
3import electronmon from "electronmon";
1import { spawn, spawnSync } from "node:child_process";
2import { watch } from "node:fs";
3import { join } from "node:path";
44import waitOn from "wait-on";
55
66import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
77
88const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);
99const devServerUrl = `http://localhost:${port}`;
10const requiredFiles = ["dist-electron/main.js", "dist-electron/preload.js", "../server/dist/index.mjs"];
11const watchedDirectories = [
12 { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) },
13 { directory: "../server/dist", files: new Set(["index.mjs"]) },
14];
15const forcedShutdownTimeoutMs = 1_500;
16const restartDebounceMs = 120;
17const workspaceDir = join(desktopDir, "..", "..");
1018
1119await waitOn({
12 resources: [
13 `tcp:${port}`,
14 "file:dist-electron/main.js",
15 "file:dist-electron/preload.js",
16 "file:../server/dist/index.mjs",
17 ],
20 resources: [`tcp:${port}`, ...requiredFiles.map((filePath) => `file:${filePath}`)],
1821});
1922
2023const childEnv = { ...process.env };
2124delete childEnv.ELECTRON_RUN_AS_NODE;
2225
23const 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
3326let shuttingDown = false;
27let restartTimer = null;
28let currentApp = null;
29let restartQueue = Promise.resolve();
30const expectedExits = new WeakSet();
31const watchers = [];
32
33function 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
41function 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
57function 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
98async 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
135function 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
157function 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}
34170
35171function killChildTree(signal) {
36172 if (process.platform === "win32") {
37173 return;
38174 }
39175
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.
41177 spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" });
42178}
43179
......@@ -45,12 +181,16 @@ async function shutdown(exitCode) {
45181 if (shuttingDown) return;
46182 shuttingDown = true;
47183
48 try {
49 await Promise.race([monitor.destroy(), new Promise((resolve) => setTimeout(resolve, 1_500))]);
50 } catch {
51 // Best effort only; fallback process kill below handles stubborn children.
184 if (restartTimer) {
185 clearTimeout(restartTimer);
186 restartTimer = null;
52187 }
53188
189 for (const watcher of watchers) {
190 watcher.close();
191 }
192
193 await stopApp();
54194 killChildTree("TERM");
55195 setTimeout(() => {
56196 killChildTree("KILL");
......@@ -59,6 +199,10 @@ async function shutdown(exitCode) {
59199 process.exit(exitCode);
60200}
61201
202startWatchers();
203cleanupStaleDevApps();
204startApp();
205
62206process.once("SIGINT", () => {
63207 void shutdown(130);
64208});