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 @@...@@ -1,43 +1,179 @@
1import { spawnSync } from "node:child_process";1import { spawn, spawnSync } from "node:child_process";
22import { watch } from "node:fs";
3import electronmon from "electronmon";3import { join } from "node:path";
4import waitOn from "wait-on";4import waitOn from "wait-on";
55
6import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";6import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
77
8const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);8const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);
9const devServerUrl = `http://localhost:${port}`;9const 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
11await waitOn({19await 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});
1922
20const childEnv = { ...process.env };23const childEnv = { ...process.env };
21delete childEnv.ELECTRON_RUN_AS_NODE;24delete 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
33let shuttingDown = false;26let 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
35function killChildTree(signal) {171function killChildTree(signal) {
36 if (process.platform === "win32") {172 if (process.platform === "win32") {
37 return;173 return;
38 }174 }
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.
41 spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" });177 spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" });
42}178}
43179
...@@ -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;
47183
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 }
53188
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}
61201
202startWatchers();
203cleanupStaleDevApps();
204startApp();
205
62process.once("SIGINT", () => {206process.once("SIGINT", () => {
63 void shutdown(130);207 void shutdown(130);
64});208});