@amd-gaia/agent-ui 0.17.3 → 0.17.5

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.
@@ -0,0 +1,234 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * port-manager.cjs — Owns port allocation, backend termination, and
6
+ * diagnostics bundle writing for the GAIA Agent UI.
7
+ *
8
+ * Extracted from main.cjs (issue #782 / T5) so main.cjs stays lean and
9
+ * the probe-and-reuse mistake cannot reappear. The correct pattern is
10
+ * "always spawn on a free random port" — if reuse is ever added later
11
+ * it must require a nonce + UID ownership match, not a bare service-string
12
+ * compare.
13
+ *
14
+ * Pure CommonJS, Node built-ins only (no Electron imports) so it can be
15
+ * unit-tested without an Electron runtime.
16
+ */
17
+
18
+ "use strict";
19
+
20
+ const net = require("net");
21
+ const fs = require("fs");
22
+ const os = require("os");
23
+ const path = require("path");
24
+ const { execFileSync } = require("child_process");
25
+
26
+ /**
27
+ * Resolve the GAIA home directory lazily so tests can point it elsewhere
28
+ * via the `GAIA_HOME_OVERRIDE` env var without re-requiring the module.
29
+ */
30
+ function gaiaHome() {
31
+ return process.env.GAIA_HOME_OVERRIDE || path.join(os.homedir(), ".gaia");
32
+ }
33
+
34
+ /**
35
+ * Ask the kernel for a free TCP port by binding to port 0, reading the
36
+ * assigned port, and closing. There is an unavoidable TOCTOU window —
37
+ * the backend process has to (re-)bind immediately or another process
38
+ * on the box can race in. In practice the window is <10 ms and the
39
+ * backend binds on startup, so this is the standard pattern.
40
+ */
41
+ function findFreePort() {
42
+ return new Promise((resolve, reject) => {
43
+ const server = net.createServer();
44
+ server.unref();
45
+ server.on("error", reject);
46
+ server.listen(0, "127.0.0.1", () => {
47
+ const { port } = server.address();
48
+ server.close((err) => {
49
+ if (err) return reject(err);
50
+ resolve(port);
51
+ });
52
+ });
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Terminate a spawned backend: SIGTERM, wait up to 3 s, then SIGKILL.
58
+ * Accepts either a ChildProcess reference (preferred — lets us
59
+ * short-circuit via `exitCode` so PID reuse can never hit us) or a raw
60
+ * pid (useful for tests). Safe to call with nullish.
61
+ */
62
+ async function killBackend(procOrPid, logger) {
63
+ const log = (logger && logger.log) || console.log;
64
+ const logError = (logger && logger.error) || console.error;
65
+
66
+ if (!procOrPid) return;
67
+
68
+ // Distinguish ChildProcess vs raw pid. A ChildProcess has both .pid
69
+ // and .exitCode/.signalCode; typeof === "object" is enough.
70
+ const isChildProcess = typeof procOrPid === "object";
71
+ const proc = isChildProcess ? procOrPid : null;
72
+ const pid = isChildProcess ? procOrPid.pid : procOrPid;
73
+
74
+ if (!pid) return;
75
+
76
+ // If we have the ChildProcess handle, trust it — the exitCode is set
77
+ // synchronously by Node's child_process before any pid reuse could
78
+ // happen, so this closes the PID-reuse TOCTOU that a pid-only API has.
79
+ if (proc && (proc.exitCode !== null || proc.signalCode !== null)) {
80
+ log(`[port-manager] child pid ${pid} already exited (exitCode=${proc.exitCode} signal=${proc.signalCode})`);
81
+ return;
82
+ }
83
+
84
+ // Is the process still alive? kill(pid, 0) throws ESRCH if not.
85
+ // Only relied on when the caller passed a raw pid (tests); callers
86
+ // with a ChildProcess were already short-circuited above.
87
+ const alive = (p) => {
88
+ try {
89
+ process.kill(p, 0);
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ };
95
+
96
+ if (!alive(pid)) {
97
+ log(`[port-manager] pid ${pid} already gone`);
98
+ return;
99
+ }
100
+
101
+ try {
102
+ process.kill(pid, "SIGTERM");
103
+ log(`[port-manager] SIGTERM sent to pid ${pid}`);
104
+ } catch (err) {
105
+ logError(`[port-manager] SIGTERM failed for pid ${pid}: ${err.message}`);
106
+ return;
107
+ }
108
+
109
+ const deadline = Date.now() + 3000;
110
+ while (Date.now() < deadline) {
111
+ if (!alive(pid)) {
112
+ log(`[port-manager] pid ${pid} exited after SIGTERM`);
113
+ return;
114
+ }
115
+ await new Promise((r) => setTimeout(r, 100));
116
+ }
117
+
118
+ try {
119
+ process.kill(pid, "SIGKILL");
120
+ log(`[port-manager] SIGKILL sent to pid ${pid} (did not exit on SIGTERM)`);
121
+ } catch (err) {
122
+ logError(`[port-manager] SIGKILL failed for pid ${pid}: ${err.message}`);
123
+ return;
124
+ }
125
+
126
+ // Poll until the kernel reaps the process so callers can safely rebind
127
+ // the port immediately after we return. 1 s cap at 50 ms intervals —
128
+ // SIGKILL is synchronous in the kernel but reaping the zombie can lag.
129
+ const killDeadline = Date.now() + 1000;
130
+ while (Date.now() < killDeadline) {
131
+ if (!alive(pid)) return;
132
+ await new Promise((r) => setTimeout(r, 50));
133
+ }
134
+ logError(
135
+ `[port-manager] pid ${pid} still alive 1s after SIGKILL — orphan risk`,
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Write a diagnostics bundle (.tar.gz) containing the known log/state
141
+ * files under ~/.gaia. Prefers shell-out to `tar` (standard on every
142
+ * Linux distro and macOS); falls back to a best-effort concatenated
143
+ * text blob if `tar` is missing (very rare — mostly Windows from node,
144
+ * where this code path isn't the primary route anyway).
145
+ *
146
+ * Returns the absolute path of the written bundle.
147
+ */
148
+ async function writeDiagnosticsBundle(destPath, logger) {
149
+ const logError = (logger && logger.error) || console.error;
150
+ if (!destPath) {
151
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
152
+ destPath = path.join(gaiaHome(), `diagnostics-${ts}.tgz`);
153
+ }
154
+
155
+ try {
156
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
157
+ } catch {
158
+ // ignore — best effort
159
+ }
160
+
161
+ const candidates = [
162
+ "electron-install.log",
163
+ "electron-install.log.prev",
164
+ "gaia.log",
165
+ "electron-main.log",
166
+ "electron-install-state.json",
167
+ ];
168
+
169
+ const present = candidates.filter((rel) =>
170
+ fs.existsSync(path.join(gaiaHome(), rel))
171
+ );
172
+
173
+ if (present.length === 0) {
174
+ // Still write an empty marker so the caller's "here is the file"
175
+ // message doesn't point at vapor.
176
+ fs.writeFileSync(destPath, "");
177
+ return destPath;
178
+ }
179
+
180
+ try {
181
+ // -C so paths inside the archive are relative (no /home/... leakage).
182
+ execFileSync("tar", ["-czf", destPath, "-C", gaiaHome(), ...present], {
183
+ stdio: ["ignore", "ignore", "pipe"],
184
+ timeout: 10000,
185
+ });
186
+ return destPath;
187
+ } catch (err) {
188
+ // Fallback: concatenate the text files into a single blob. This is
189
+ // NOT a tar archive; rename so we don't lie about the format.
190
+ const fallback = destPath.replace(/\.tgz$/, ".txt");
191
+ const chunks = [];
192
+ for (const rel of present) {
193
+ const abs = path.join(gaiaHome(), rel);
194
+ try {
195
+ chunks.push(`==== ${rel} ====\n`);
196
+ chunks.push(fs.readFileSync(abs, "utf8"));
197
+ chunks.push("\n");
198
+ } catch (readErr) {
199
+ chunks.push(`(could not read ${rel}: ${readErr.message})\n`);
200
+ }
201
+ }
202
+ fs.writeFileSync(fallback, chunks.join(""));
203
+ logError(
204
+ `[port-manager] tar unavailable (${err.message}); wrote plain-text bundle to ${fallback}`
205
+ );
206
+ return fallback;
207
+ }
208
+ }
209
+
210
+ class PortManager {
211
+ // Instance wrappers so callers can pattern-match the other services
212
+ // (TrayManager, AgentProcessManager) and inject a logger once.
213
+ constructor({ logger } = {}) {
214
+ this.logger = logger || null;
215
+ }
216
+
217
+ findFreePort() {
218
+ return findFreePort();
219
+ }
220
+
221
+ killBackend(procOrPid) {
222
+ return killBackend(procOrPid, this.logger);
223
+ }
224
+
225
+ writeDiagnosticsBundle(destPath) {
226
+ return writeDiagnosticsBundle(destPath, this.logger);
227
+ }
228
+ }
229
+
230
+ module.exports = PortManager;
231
+ module.exports.PortManager = PortManager;
232
+ module.exports.findFreePort = findFreePort;
233
+ module.exports.killBackend = killBackend;
234
+ module.exports.writeDiagnosticsBundle = writeDiagnosticsBundle;