@amd-gaia/agent-ui 0.17.2 → 0.17.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-eQemgF08.css +1 -0
- package/dist/assets/index-iAjQas0m.js +443 -0
- package/dist/index.html +2 -2
- package/main.cjs +246 -43
- package/package.json +1 -1
- package/services/agent-seeder.cjs +301 -0
- package/services/backend-installer.cjs +325 -62
- package/services/port-manager.cjs +234 -0
- package/dist/assets/index-CmLC9Yd5.js +0 -437
- package/dist/assets/index-DdsmIsYZ.css +0 -1
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/png" href="./favicon.png" />
|
|
7
7
|
<title>GAIA</title>
|
|
8
|
-
<script type="module" crossorigin src="./assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
8
|
+
<script type="module" crossorigin src="./assets/index-iAjQas0m.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="./assets/index-eQemgF08.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/main.cjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// services/notification-service.js — Desktop notifications + permission prompts (T5)
|
|
14
14
|
// preload.cjs — contextBridge for IPC channels (T0/T1)
|
|
15
15
|
|
|
16
|
-
const { app, BrowserWindow, shell } = require("electron");
|
|
16
|
+
const { app, BrowserWindow, dialog, shell } = require("electron");
|
|
17
17
|
const path = require("path");
|
|
18
18
|
const fs = require("fs");
|
|
19
19
|
const os = require("os");
|
|
@@ -23,15 +23,106 @@ const { spawn } = require("child_process");
|
|
|
23
23
|
const TrayManager = require("./services/tray-manager.cjs");
|
|
24
24
|
const AgentProcessManager = require("./services/agent-process-manager.cjs");
|
|
25
25
|
const NotificationService = require("./services/notification-service.cjs");
|
|
26
|
+
const PortManager = require("./services/port-manager.cjs");
|
|
26
27
|
const backendInstaller = require("./services/backend-installer.cjs");
|
|
27
28
|
const installerProgressDialog = require("./services/backend-installer-progress-dialog.cjs");
|
|
28
29
|
const autoUpdater = require("./services/auto-updater.cjs");
|
|
30
|
+
const agentSeeder = require("./services/agent-seeder.cjs");
|
|
31
|
+
|
|
32
|
+
// ── F7: Ozone hint (issue #782) ─────────────────────────────────────────────
|
|
33
|
+
// Electron-recommended switch for distro-agnostic Linux behaviour: picks
|
|
34
|
+
// Wayland on Wayland sessions, X11 elsewhere. Must be set before
|
|
35
|
+
// app.whenReady() fires.
|
|
36
|
+
app.commandLine.appendSwitch("ozone-platform-hint", "auto");
|
|
37
|
+
|
|
38
|
+
// ── F7: --no-sandbox on Linux (issue #782) ───────────────────────────────────
|
|
39
|
+
// chrome-sandbox is deleted from the packaged tree by after-pack.cjs so
|
|
40
|
+
// Chromium must use its unprivileged user-namespace sandbox on every launch
|
|
41
|
+
// path. The .desktop Exec= line already carries --no-sandbox via
|
|
42
|
+
// electron-builder.yml linux.executableArgs, but direct `./GAIA.AppImage`
|
|
43
|
+
// invocations bypass the .desktop entry. Appending the switch here makes
|
|
44
|
+
// all Linux launch paths behave identically.
|
|
45
|
+
if (process.platform === "linux") {
|
|
46
|
+
app.commandLine.appendSwitch("no-sandbox");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── F7: Log tee to ~/.gaia/electron-main.log (issue #782) ───────────────────
|
|
50
|
+
// Users often launch AppImages by double-click, not from a terminal, so
|
|
51
|
+
// console output vanishes. Mirror console.log/error to a file so the
|
|
52
|
+
// diagnostics bundler has something to attach.
|
|
53
|
+
(function installMainLogTee() {
|
|
54
|
+
try {
|
|
55
|
+
const gaiaDir = path.join(os.homedir(), ".gaia");
|
|
56
|
+
try { fs.mkdirSync(gaiaDir, { recursive: true }); } catch { /* ignore */ }
|
|
57
|
+
const logPath = path.join(gaiaDir, "electron-main.log");
|
|
58
|
+
|
|
59
|
+
// Rotate if > 5 MB — truncate to last ~5 MB on startup.
|
|
60
|
+
try {
|
|
61
|
+
const st = fs.statSync(logPath);
|
|
62
|
+
if (st.size > 5 * 1024 * 1024) {
|
|
63
|
+
const fd = fs.openSync(logPath, "r");
|
|
64
|
+
const keep = 5 * 1024 * 1024;
|
|
65
|
+
const buf = Buffer.alloc(keep);
|
|
66
|
+
// readSync can return fewer bytes than requested; only write
|
|
67
|
+
// what we actually read so we don't append zero-padding.
|
|
68
|
+
const bytesRead = fs.readSync(
|
|
69
|
+
fd,
|
|
70
|
+
buf,
|
|
71
|
+
0,
|
|
72
|
+
keep,
|
|
73
|
+
Math.max(0, st.size - keep),
|
|
74
|
+
);
|
|
75
|
+
fs.closeSync(fd);
|
|
76
|
+
fs.writeFileSync(logPath, buf.subarray(0, bytesRead));
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// ENOENT or permission — best-effort; just fall through.
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const stream = fs.createWriteStream(logPath, { flags: "a" });
|
|
83
|
+
stream.write(
|
|
84
|
+
`\n──── electron-main opened (${new Date().toISOString()}) pid=${process.pid} ────\n`
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const flushAndEnd = () => {
|
|
88
|
+
try { stream.end(); } catch { /* ignore */ }
|
|
89
|
+
};
|
|
90
|
+
process.on("exit", flushAndEnd);
|
|
91
|
+
|
|
92
|
+
const wrap = (origFn, level) => (...args) => {
|
|
93
|
+
try {
|
|
94
|
+
const line = args
|
|
95
|
+
.map((a) =>
|
|
96
|
+
typeof a === "string"
|
|
97
|
+
? a
|
|
98
|
+
: a instanceof Error
|
|
99
|
+
? (a.stack || a.message || String(a))
|
|
100
|
+
: JSON.stringify(a)
|
|
101
|
+
)
|
|
102
|
+
.join(" ");
|
|
103
|
+
stream.write(`[${new Date().toISOString()}] ${level} ${line}\n`);
|
|
104
|
+
} catch {
|
|
105
|
+
// swallow — we must not recurse into console.error from here
|
|
106
|
+
}
|
|
107
|
+
return origFn.apply(console, args);
|
|
108
|
+
};
|
|
109
|
+
console.log = wrap(console.log.bind(console), "INFO");
|
|
110
|
+
console.warn = wrap(console.warn.bind(console), "WARN");
|
|
111
|
+
console.error = wrap(console.error.bind(console), "ERROR");
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// If this fails, we silently keep the original console — the app must
|
|
114
|
+
// not refuse to launch over a log-tee failure.
|
|
115
|
+
process.stderr.write(`[main] log tee failed: ${err.message}\n`);
|
|
116
|
+
}
|
|
117
|
+
})();
|
|
29
118
|
|
|
30
119
|
// ── Configuration ──────────────────────────────────────────────────────────
|
|
31
120
|
|
|
32
121
|
const APP_NAME = "GAIA";
|
|
33
|
-
|
|
34
|
-
|
|
122
|
+
// Default fallback only — at runtime we always allocate a free random port
|
|
123
|
+
// via PortManager.findFreePort() to avoid EADDRINUSE on zombie backends
|
|
124
|
+
// from prior aborted sessions (issue #782 / T5).
|
|
125
|
+
const DEFAULT_BACKEND_PORT = 4200;
|
|
35
126
|
const STARTUP_TIMEOUT = 30000;
|
|
36
127
|
|
|
37
128
|
// Parse CLI args (T11: --minimized flag for auto-start)
|
|
@@ -58,6 +149,10 @@ const windowConfig = appConfig.window || {
|
|
|
58
149
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
59
150
|
|
|
60
151
|
let backendProcess = null;
|
|
152
|
+
let backendPort = DEFAULT_BACKEND_PORT;
|
|
153
|
+
let healthCheckUrl = `http://localhost:${backendPort}/api/health`;
|
|
154
|
+
let backendStderrTail = [];
|
|
155
|
+
let isIntentionalKill = false;
|
|
61
156
|
let mainWindow = null;
|
|
62
157
|
|
|
63
158
|
/** @type {TrayManager | null} */
|
|
@@ -69,6 +164,11 @@ let agentProcessManager = null;
|
|
|
69
164
|
/** @type {NotificationService | null} */
|
|
70
165
|
let notificationService = null;
|
|
71
166
|
|
|
167
|
+
/** @type {PortManager} */
|
|
168
|
+
const portManager = new PortManager({
|
|
169
|
+
logger: { log: console.log.bind(console), error: console.error.bind(console) },
|
|
170
|
+
});
|
|
171
|
+
|
|
72
172
|
/**
|
|
73
173
|
* Set to true when the user explicitly quits (via tray "Quit" or Cmd+Q).
|
|
74
174
|
* Prevents minimize-to-tray from intercepting the close event.
|
|
@@ -85,7 +185,7 @@ let isQuitting = false;
|
|
|
85
185
|
* Returns the ChildProcess, or null if the gaia binary cannot be found
|
|
86
186
|
* (shouldn't happen post-ensureBackend, but we guard just in case).
|
|
87
187
|
*/
|
|
88
|
-
function startBackend() {
|
|
188
|
+
async function startBackend() {
|
|
89
189
|
const gaiaCmd = backendInstaller.findGaiaBin();
|
|
90
190
|
|
|
91
191
|
if (!gaiaCmd) {
|
|
@@ -95,11 +195,27 @@ function startBackend() {
|
|
|
95
195
|
return null;
|
|
96
196
|
}
|
|
97
197
|
|
|
98
|
-
|
|
198
|
+
// F5: always spawn on a free random port. Never reuse/probe — the
|
|
199
|
+
// probe-and-reuse path is spoofable and leaves orphans (issue #782).
|
|
200
|
+
try {
|
|
201
|
+
backendPort = await portManager.findFreePort();
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.warn(
|
|
204
|
+
`[main] findFreePort failed (${err.message}); falling back to ${DEFAULT_BACKEND_PORT}`
|
|
205
|
+
);
|
|
206
|
+
backendPort = DEFAULT_BACKEND_PORT;
|
|
207
|
+
}
|
|
208
|
+
healthCheckUrl = `http://localhost:${backendPort}/api/health`;
|
|
209
|
+
|
|
210
|
+
console.log(`Starting backend: ${gaiaCmd} chat --ui --ui-port ${backendPort}`);
|
|
211
|
+
|
|
212
|
+
// Reset per-spawn state so a fresh crash dialog doesn't mix tails.
|
|
213
|
+
backendStderrTail = [];
|
|
214
|
+
isIntentionalKill = false;
|
|
99
215
|
|
|
100
216
|
const child = spawn(
|
|
101
217
|
gaiaCmd,
|
|
102
|
-
["chat", "--ui", "--ui-port", String(
|
|
218
|
+
["chat", "--ui", "--ui-port", String(backendPort)],
|
|
103
219
|
{
|
|
104
220
|
cwd: os.homedir(), // Electron's cwd is "/" on macOS when launched from Finder
|
|
105
221
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -115,24 +231,85 @@ function startBackend() {
|
|
|
115
231
|
});
|
|
116
232
|
|
|
117
233
|
child.stderr.on("data", (data) => {
|
|
118
|
-
const
|
|
119
|
-
|
|
234
|
+
const chunk = data.toString();
|
|
235
|
+
chunk.split(/\r?\n/).forEach((line) => {
|
|
236
|
+
if (!line) return;
|
|
237
|
+
console.log(`[backend] ${line}`);
|
|
238
|
+
// Cap per-line length so pathological no-newline backend output
|
|
239
|
+
// can't balloon the in-memory tail or the crash-dialog body.
|
|
240
|
+
const capped =
|
|
241
|
+
line.length > 2048 ? line.slice(0, 2048) + "…[truncated]" : line;
|
|
242
|
+
backendStderrTail.push(capped);
|
|
243
|
+
if (backendStderrTail.length > 20) backendStderrTail.shift();
|
|
244
|
+
});
|
|
120
245
|
});
|
|
121
246
|
|
|
122
247
|
child.on("error", (err) => {
|
|
123
248
|
console.error("Failed to start backend:", err.message);
|
|
124
249
|
});
|
|
125
250
|
|
|
126
|
-
child.on("exit", (code) => {
|
|
251
|
+
child.on("exit", (code, signal) => {
|
|
127
252
|
if (code !== 0 && code !== null) {
|
|
128
|
-
console.error(`Backend exited with code ${code}`);
|
|
253
|
+
console.error(`Backend exited with code ${code} (signal=${signal})`);
|
|
129
254
|
}
|
|
255
|
+
const crashed = !isIntentionalKill && code !== 0 && code !== null;
|
|
130
256
|
backendProcess = null;
|
|
257
|
+
|
|
258
|
+
if (crashed && !isQuitting) {
|
|
259
|
+
// Fire-and-forget — don't block the event loop.
|
|
260
|
+
void handleBackendCrash(code, signal);
|
|
261
|
+
}
|
|
131
262
|
});
|
|
132
263
|
|
|
133
264
|
return child;
|
|
134
265
|
}
|
|
135
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Show a user-facing crash dialog with the last ~20 stderr lines and
|
|
269
|
+
* offer to write a diagnostics bundle. Invoked from child.on("exit")
|
|
270
|
+
* when the kill was NOT initiated by us.
|
|
271
|
+
*/
|
|
272
|
+
async function handleBackendCrash(code, signal) {
|
|
273
|
+
const tail = backendStderrTail.slice(-20).join("\n") || "(no stderr captured)";
|
|
274
|
+
const detail =
|
|
275
|
+
`The GAIA backend exited unexpectedly (code=${code}, signal=${signal}).\n\n` +
|
|
276
|
+
`Recent log output:\n${tail}`;
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const choice = await dialog.showMessageBox({
|
|
280
|
+
type: "error",
|
|
281
|
+
title: "GAIA backend crashed",
|
|
282
|
+
message: "GAIA backend crashed",
|
|
283
|
+
detail,
|
|
284
|
+
buttons: ["Copy diagnostics", "Quit"],
|
|
285
|
+
defaultId: 0,
|
|
286
|
+
cancelId: 1,
|
|
287
|
+
noLink: true,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (choice.response === 0) {
|
|
291
|
+
try {
|
|
292
|
+
const bundlePath = await portManager.writeDiagnosticsBundle();
|
|
293
|
+
await dialog.showMessageBox({
|
|
294
|
+
type: "info",
|
|
295
|
+
title: "Diagnostics saved",
|
|
296
|
+
message: "Diagnostics bundle written",
|
|
297
|
+
detail: `Attach this file to your bug report:\n${bundlePath}`,
|
|
298
|
+
buttons: ["OK"],
|
|
299
|
+
});
|
|
300
|
+
} catch (err) {
|
|
301
|
+
console.error("[main] Could not write diagnostics bundle:", err.message);
|
|
302
|
+
dialog.showErrorBox(
|
|
303
|
+
"Could not write diagnostics",
|
|
304
|
+
`Failed to write diagnostics bundle: ${err.message}`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.error("[main] handleBackendCrash failed:", err.message);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
136
313
|
async function waitForBackend(timeoutMs) {
|
|
137
314
|
const start = Date.now();
|
|
138
315
|
const http = require("http");
|
|
@@ -140,7 +317,7 @@ async function waitForBackend(timeoutMs) {
|
|
|
140
317
|
while (Date.now() - start < timeoutMs) {
|
|
141
318
|
try {
|
|
142
319
|
await new Promise((resolve, reject) => {
|
|
143
|
-
const req = http.get(
|
|
320
|
+
const req = http.get(healthCheckUrl, (res) => {
|
|
144
321
|
if (res.statusCode === 200) {
|
|
145
322
|
resolve();
|
|
146
323
|
} else {
|
|
@@ -220,10 +397,12 @@ function createWindow() {
|
|
|
220
397
|
|
|
221
398
|
// Show window when ready (unless --minimized or startMinimized config)
|
|
222
399
|
mainWindow.once("ready-to-show", () => {
|
|
400
|
+
console.log("[main] ready-to-show fired");
|
|
223
401
|
const shouldStartMinimized =
|
|
224
402
|
startMinimized || (trayManager && trayManager.startMinimized);
|
|
225
403
|
|
|
226
404
|
if (!shouldStartMinimized) {
|
|
405
|
+
console.log("[main] mainWindow.show() called");
|
|
227
406
|
mainWindow.show();
|
|
228
407
|
} else {
|
|
229
408
|
console.log("[main] Starting minimized to tray");
|
|
@@ -240,9 +419,15 @@ async function loadApp() {
|
|
|
240
419
|
// Always load the bundled frontend from the asar. The backend only
|
|
241
420
|
// serves the API (no frontend files in the pip package), so loading
|
|
242
421
|
// http://localhost:4200/ would show raw JSON instead of the UI.
|
|
422
|
+
//
|
|
423
|
+
// Pass the real backend base URL as a query parameter so the renderer
|
|
424
|
+
// can reach whatever random port port-manager picked (see #851).
|
|
425
|
+
// Without this, the compiled bundle falls back to its hardcoded
|
|
426
|
+
// `http://localhost:4200/api` and every API call 404s.
|
|
243
427
|
const indexPath = path.join(distPath, "index.html");
|
|
244
|
-
|
|
245
|
-
|
|
428
|
+
const apiBase = `http://127.0.0.1:${backendPort}/api`;
|
|
429
|
+
console.log("Loading app from:", indexPath, "api:", apiBase);
|
|
430
|
+
await mainWindow.loadFile(indexPath, { query: { api: apiBase } });
|
|
246
431
|
} else {
|
|
247
432
|
// Show a simple loading/error page
|
|
248
433
|
mainWindow.loadURL(
|
|
@@ -253,7 +438,7 @@ async function loadApp() {
|
|
|
253
438
|
<div style="text-align:center;">
|
|
254
439
|
<h1>${APP_NAME}</h1>
|
|
255
440
|
<p>Waiting for backend to start...</p>
|
|
256
|
-
<p style="color:#888; font-size:12px;">Backend: http://localhost:${
|
|
441
|
+
<p style="color:#888; font-size:12px;">Backend: http://localhost:${backendPort}</p>
|
|
257
442
|
</div>
|
|
258
443
|
</body>
|
|
259
444
|
</html>`
|
|
@@ -270,7 +455,7 @@ function initializeServices() {
|
|
|
270
455
|
agentProcessManager = new AgentProcessManager(mainWindow);
|
|
271
456
|
|
|
272
457
|
// T1: Tray Manager (system tray icon + context menu)
|
|
273
|
-
trayManager = new TrayManager(mainWindow, { backendPort
|
|
458
|
+
trayManager = new TrayManager(mainWindow, { backendPort });
|
|
274
459
|
trayManager.create();
|
|
275
460
|
|
|
276
461
|
// T5: Notification Service (routes agent notifications to OS + renderer)
|
|
@@ -368,6 +553,7 @@ async function bootstrapBackend() {
|
|
|
368
553
|
try {
|
|
369
554
|
await backendInstaller.ensureBackend({
|
|
370
555
|
onProgress: progress.onProgress,
|
|
556
|
+
isPackaged: app.isPackaged,
|
|
371
557
|
});
|
|
372
558
|
progress.close();
|
|
373
559
|
console.log("[main] Backend bootstrap complete");
|
|
@@ -454,6 +640,25 @@ app.on("second-instance", (_event, _argv, _cwd) => {
|
|
|
454
640
|
});
|
|
455
641
|
|
|
456
642
|
app.whenReady().then(async () => {
|
|
643
|
+
// Phase 0: seed bundled agents BEFORE the Python backend starts, so the
|
|
644
|
+
// agent registry sees them on its first discovery pass. Failures here are
|
|
645
|
+
// non-fatal — the app must still launch even if seeding is blocked (e.g.
|
|
646
|
+
// permission error on ~/.gaia/agents).
|
|
647
|
+
try {
|
|
648
|
+
const seedResult = await agentSeeder.seedBundledAgents();
|
|
649
|
+
if (seedResult.seeded.length > 0) {
|
|
650
|
+
console.log("[main] Seeded agents:", seedResult.seeded);
|
|
651
|
+
}
|
|
652
|
+
if (seedResult.errors.length > 0) {
|
|
653
|
+
console.warn(
|
|
654
|
+
"[main] Agent seeding errors:",
|
|
655
|
+
seedResult.errors.map((e) => e.id)
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
} catch (err) {
|
|
659
|
+
console.warn("[main] Agent seeding failed (non-fatal):", err);
|
|
660
|
+
}
|
|
661
|
+
|
|
457
662
|
// Phase A: ensure the Python backend is installed BEFORE creating the
|
|
458
663
|
// main window. The progress dialog owns the UI during this phase.
|
|
459
664
|
const bootstrapOk = await bootstrapBackend();
|
|
@@ -464,7 +669,7 @@ app.whenReady().then(async () => {
|
|
|
464
669
|
}
|
|
465
670
|
|
|
466
671
|
// Start the Python backend
|
|
467
|
-
backendProcess = startBackend();
|
|
672
|
+
backendProcess = await startBackend();
|
|
468
673
|
|
|
469
674
|
// Create the window (hidden until ready-to-show)
|
|
470
675
|
createWindow();
|
|
@@ -500,7 +705,7 @@ app.whenReady().then(async () => {
|
|
|
500
705
|
console.log("Waiting for backend to start...");
|
|
501
706
|
const ready = await waitForBackend(STARTUP_TIMEOUT);
|
|
502
707
|
if (ready) {
|
|
503
|
-
console.log("Backend API is ready on port",
|
|
708
|
+
console.log("Backend API is ready on port", backendPort);
|
|
504
709
|
} else {
|
|
505
710
|
console.warn("Backend did not respond within timeout.");
|
|
506
711
|
}
|
|
@@ -555,6 +760,9 @@ let cleanupDone = false;
|
|
|
555
760
|
|
|
556
761
|
app.on("before-quit", () => {
|
|
557
762
|
isQuitting = true;
|
|
763
|
+
// Mark any subsequent backend exit as intentional so we don't pop the
|
|
764
|
+
// crash dialog during normal shutdown.
|
|
765
|
+
isIntentionalKill = true;
|
|
558
766
|
});
|
|
559
767
|
|
|
560
768
|
app.on("will-quit", (event) => {
|
|
@@ -608,39 +816,34 @@ async function cleanup() {
|
|
|
608
816
|
trayManager = null;
|
|
609
817
|
}
|
|
610
818
|
|
|
611
|
-
// Stop the Python backend
|
|
819
|
+
// Stop the Python backend via the port-manager (SIGTERM → wait 3 s → SIGKILL).
|
|
820
|
+
// F5: previously we did this inline; extracted so main.cjs stays lean and
|
|
821
|
+
// to prevent orphan leaks across runs (issue #782).
|
|
612
822
|
if (backendProcess) {
|
|
613
823
|
console.log("Stopping backend process...");
|
|
614
|
-
const proc = backendProcess;
|
|
824
|
+
const proc = backendProcess;
|
|
615
825
|
backendProcess = null;
|
|
826
|
+
isIntentionalKill = true;
|
|
616
827
|
|
|
617
828
|
try {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
//
|
|
829
|
+
// Pass the ChildProcess handle (not a bare pid) so port-manager
|
|
830
|
+
// can short-circuit via `proc.exitCode` and close the PID-reuse
|
|
831
|
+
// TOCTOU window.
|
|
832
|
+
await portManager.killBackend(proc);
|
|
833
|
+
} catch (err) {
|
|
834
|
+
console.error("Error stopping backend:", err.message);
|
|
621
835
|
}
|
|
622
836
|
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
resolve
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
try {
|
|
633
|
-
proc.kill("SIGKILL");
|
|
634
|
-
} catch {
|
|
635
|
-
// Already dead
|
|
636
|
-
}
|
|
637
|
-
resolve();
|
|
638
|
-
}, 3000);
|
|
639
|
-
|
|
640
|
-
proc.once("exit", () => {
|
|
641
|
-
clearTimeout(forceKillTimer);
|
|
642
|
-
resolve();
|
|
837
|
+
// If the child object still reports alive (race), give its exit
|
|
838
|
+
// listener a brief moment to fire before we continue teardown.
|
|
839
|
+
if (proc.exitCode === null) {
|
|
840
|
+
await new Promise((resolve) => {
|
|
841
|
+
const timer = setTimeout(resolve, 500);
|
|
842
|
+
proc.once("exit", () => {
|
|
843
|
+
clearTimeout(timer);
|
|
844
|
+
resolve();
|
|
845
|
+
});
|
|
643
846
|
});
|
|
644
|
-
}
|
|
847
|
+
}
|
|
645
848
|
}
|
|
646
849
|
}
|
package/package.json
CHANGED