@amd-gaia/agent-ui 0.17.3 → 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-B4Qzv7Ys.js → index-iAjQas0m.js} +78 -78
- package/dist/index.html +1 -1
- package/main.cjs +226 -43
- package/package.json +1 -1
- package/services/backend-installer.cjs +325 -62
- package/services/port-manager.cjs +234 -0
|
@@ -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;
|