@amd-gaia/agent-ui 0.25.0 → 0.25.1-rc.10
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/assets/tray-icon.png +0 -0
- package/assets/tray-icon@2x.png +0 -0
- package/dist/assets/AdvancedSettings-CtYKNNWc.js +1 -0
- package/dist/assets/AdvancedSettings-D5VKpkPY.css +1 -0
- package/dist/assets/ConnectorsPane-Ctfp9d5s.js +1 -0
- package/dist/assets/ConnectorsPane-Dzu2DRTc.css +1 -0
- package/dist/assets/DocumentLibrary-CIKp6qld.css +1 -0
- package/dist/assets/DocumentLibrary-Fp70O1-8.js +4 -0
- package/dist/assets/FileBrowser-3H3DoJIm.js +4 -0
- package/dist/assets/FileBrowser-D04I8Pvv.css +1 -0
- package/dist/assets/MemoryDashboard-D5HzmtDi.js +1 -0
- package/dist/assets/MemoryDashboard-Mo721-BI.css +1 -0
- package/dist/assets/MobileAccessModal-B9peVPY4.js +2 -0
- package/dist/assets/MobileAccessModal-DuVy9F1u.css +1 -0
- package/dist/assets/ScheduleManager-DotJl5JW.css +1 -0
- package/dist/assets/ScheduleManager-HcGB9P6m.js +2 -0
- package/dist/assets/{jsx-runtime-DVeaULmT.js → apiBase-y00JgRBP.js} +1 -1
- package/dist/assets/eye-B6FejHNG.js +1 -0
- package/dist/assets/fraunces-latin-BwwriW9Y.woff2 +0 -0
- package/dist/assets/index-D2UMLtwY.js +74 -0
- package/dist/assets/index-DF5qku9L.css +1 -0
- package/dist/assets/inter-latin-C2S99t-D.woff2 +0 -0
- package/dist/assets/inter-latin-ext-CFHvXkgd.woff2 +0 -0
- package/dist/assets/message-square-Dwm7y3C0.js +1 -0
- package/dist/fonts-OFL.txt +101 -0
- package/dist/index.html +6 -6
- package/main-safety-net.cjs +54 -7
- package/main.cjs +276 -120
- package/package.json +3 -1
- package/preload.cjs +24 -0
- package/services/agent-process-manager.cjs +116 -13
- package/services/app-icon.cjs +29 -0
- package/services/auto-updater.cjs +26 -2
- package/services/backend-crash-dialog.cjs +102 -0
- package/services/backend-installer-progress-dialog.cjs +71 -43
- package/services/backend-installer.cjs +233 -25
- package/services/chromium-switches.cjs +29 -0
- package/services/keep-awake.cjs +52 -0
- package/services/notification-service.cjs +62 -0
- package/services/port-manager.cjs +19 -70
- package/services/process-tree.cjs +328 -0
- package/services/quit-cleanup.cjs +86 -0
- package/services/quit-guard.cjs +242 -0
- package/services/tray-icons.cjs +174 -0
- package/services/tray-manager.cjs +582 -83
- package/services/tray-state.cjs +231 -0
- package/services/window-keys.cjs +43 -0
- package/assets/tray-icon.ico +0 -0
- package/dist/assets/AdvancedSettings-5cX0pGZc.js +0 -1
- package/dist/assets/AdvancedSettings-Ce6EvcAe.css +0 -1
- package/dist/assets/ConnectorsPane-CG7atLb2.css +0 -1
- package/dist/assets/ConnectorsPane-CuWxzcBY.js +0 -1
- package/dist/assets/DocumentLibrary-BdAKDe8H.js +0 -1
- package/dist/assets/DocumentLibrary-DrIP_032.css +0 -1
- package/dist/assets/FileBrowser-B8s6hKC5.js +0 -4
- package/dist/assets/FileBrowser-Bj6aB6Ro.css +0 -1
- package/dist/assets/MemoryDashboard-DQxtAW90.css +0 -1
- package/dist/assets/MemoryDashboard-DtolA-HZ.js +0 -1
- package/dist/assets/MobileAccessModal-C3nWkpWA.js +0 -2
- package/dist/assets/MobileAccessModal-C_t8DG0n.css +0 -1
- package/dist/assets/ScheduleManager-CIescbX6.css +0 -1
- package/dist/assets/ScheduleManager-HHdD6sko.js +0 -2
- package/dist/assets/dm-sans-latin-Xz1IZZA0.woff2 +0 -0
- package/dist/assets/index-CFVpPQal.css +0 -1
- package/dist/assets/index-DEPbenxC.js +0 -60
- package/dist/assets/message-square-Ccmbk96j.js +0 -1
- package/dist/assets/space-mono-400-Co7bH5Hm.woff2 +0 -0
- package/dist/assets/useEscapeToClose-B8fw5mn-.js +0 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stop a child process together with everything it started.
|
|
6
|
+
*
|
|
7
|
+
* Killing only the direct child leaves its own children running after the app
|
|
8
|
+
* is gone: the uv `gaia.exe` launcher's Python, a PyInstaller bootloader's
|
|
9
|
+
* server, or the MCP servers and shell commands an agent spawned. They keep
|
|
10
|
+
* ports and file handles, and the next launch trips over them.
|
|
11
|
+
*
|
|
12
|
+
* The tree is read from the OS process table (`ps` on Linux and macOS,
|
|
13
|
+
* Win32_Process on Windows), walked by parent pid, and every member is
|
|
14
|
+
* signalled. GAIA's shared servers (the daemon and Lemonade) are left alone:
|
|
15
|
+
* every GAIA client shares them, they outlive the app on purpose, and the
|
|
16
|
+
* daemon unloads its models itself once the app is gone (GAIA_APP_PID).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
"use strict";
|
|
20
|
+
|
|
21
|
+
const childProcess = require("child_process");
|
|
22
|
+
|
|
23
|
+
const DEFAULT_GRACE_MS = 3000;
|
|
24
|
+
const DEFAULT_FORCE_WAIT_MS = 1000;
|
|
25
|
+
// PowerShell's CIM query takes about a second on an idle PC and several on a
|
|
26
|
+
// loaded one; timing out strands every process the agent started. `ps` is fast.
|
|
27
|
+
const WINDOWS_LIST_TIMEOUT_MS = 20000;
|
|
28
|
+
const POSIX_LIST_TIMEOUT_MS = 5000;
|
|
29
|
+
const POLL_MS = 50;
|
|
30
|
+
|
|
31
|
+
// The daemon by its module or CLI verb; Lemonade by its executable's name,
|
|
32
|
+
// so a tool that merely mentions Lemonade (--lemonade-url) is still stopped.
|
|
33
|
+
const SHARED_SERVER_PATTERN =
|
|
34
|
+
/(?:-m\s+gaia\.daemon\b)|(?:\bgaia(?:\.exe)?["']?\s+daemon\b)|(?:^|[\\/"'\s])(?:lemonade|lemonade-server|lemonadeserver|lemonade-tray|lemond)(?:\.exe)?(?=["'\s]|$)/i;
|
|
35
|
+
|
|
36
|
+
/** Longest one read of the process table may take on `platform` (ms). */
|
|
37
|
+
function listTimeoutMs(platform = process.platform) {
|
|
38
|
+
return platform === "win32" ? WINDOWS_LIST_TIMEOUT_MS : POSIX_LIST_TIMEOUT_MS;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Longest killProcessTree can take (ms): one table read, the grace period,
|
|
43
|
+
* a second read outside Windows, and the SIGKILL wait.
|
|
44
|
+
*/
|
|
45
|
+
function worstCaseKillMs({
|
|
46
|
+
platform = process.platform,
|
|
47
|
+
graceMs = DEFAULT_GRACE_MS,
|
|
48
|
+
forceWaitMs = DEFAULT_FORCE_WAIT_MS,
|
|
49
|
+
} = {}) {
|
|
50
|
+
const reads = platform === "win32" ? 1 : 2;
|
|
51
|
+
return reads * listTimeoutMs(platform) + graceMs + forceWaitMs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True for a command line that belongs to a server shared by every GAIA client. */
|
|
55
|
+
function isSharedServer(command) {
|
|
56
|
+
return SHARED_SERVER_PATTERN.test(command || "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Linux and macOS re-parent an orphan, so its ppid is always current.
|
|
60
|
+
function parsePosixTable(stdout) {
|
|
61
|
+
const rows = [];
|
|
62
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
63
|
+
const m = /^\s*(\d+)\s+(\d+)\s?(.*)$/.exec(line);
|
|
64
|
+
if (m) rows.push({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3].trim(), started: null });
|
|
65
|
+
}
|
|
66
|
+
return rows;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Windows never updates ParentProcessId when the parent dies, and reuses
|
|
70
|
+
// pids, so each row carries its creation time (FILETIME) for the walk to
|
|
71
|
+
// reject a "child" older than the parent it claims.
|
|
72
|
+
function parseWindowsTable(stdout) {
|
|
73
|
+
const rows = [];
|
|
74
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
75
|
+
const parts = line.split("\t");
|
|
76
|
+
if (parts.length < 3 || !/^\d+$/.test(parts[0]) || !/^\d+$/.test(parts[1])) continue;
|
|
77
|
+
rows.push({
|
|
78
|
+
pid: Number(parts[0]),
|
|
79
|
+
ppid: Number(parts[1]),
|
|
80
|
+
started: /^\d+$/.test(parts[2]) ? BigInt(parts[2]) : null,
|
|
81
|
+
command: parts.slice(3).join("\t").trim(),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return rows;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read the OS process table as `[{ pid, ppid, command }]`.
|
|
89
|
+
* Rejects with what failed and which command ran; never returns an empty
|
|
90
|
+
* table in place of an error.
|
|
91
|
+
*/
|
|
92
|
+
function listProcesses({ platform = process.platform, execFile = childProcess.execFile } = {}) {
|
|
93
|
+
const [cmd, args, parse] =
|
|
94
|
+
platform === "win32"
|
|
95
|
+
? [
|
|
96
|
+
"powershell.exe",
|
|
97
|
+
[
|
|
98
|
+
"-NoProfile",
|
|
99
|
+
"-NonInteractive",
|
|
100
|
+
"-Command",
|
|
101
|
+
"Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId)`t$($_.ParentProcessId)`t$(if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() })`t$($_.CommandLine)\" }",
|
|
102
|
+
],
|
|
103
|
+
parseWindowsTable,
|
|
104
|
+
]
|
|
105
|
+
: ["ps", ["-A", "-ww", "-o", "pid=", "-o", "ppid=", "-o", "command="], parsePosixTable];
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
execFile(
|
|
108
|
+
cmd,
|
|
109
|
+
args,
|
|
110
|
+
{ windowsHide: true, timeout: listTimeoutMs(platform), maxBuffer: 32 * 1024 * 1024 },
|
|
111
|
+
(err, stdout) => {
|
|
112
|
+
if (err) {
|
|
113
|
+
reject(new Error(`could not read the process table with ${cmd}: ${err.message}`, { cause: err }));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
resolve(parse(String(stdout)));
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Every descendant of `rootPid`, parents before children, skipping shared
|
|
124
|
+
* servers and anything they started.
|
|
125
|
+
* @returns {{ descendants: {pid:number, command:string}[], skipped: {pid:number, command:string}[] }}
|
|
126
|
+
*/
|
|
127
|
+
function descendantsFromTable(table, rootPid) {
|
|
128
|
+
const byPid = new Map(table.map((r) => [r.pid, r]));
|
|
129
|
+
const children = new Map();
|
|
130
|
+
for (const row of table) {
|
|
131
|
+
if (row.pid === row.ppid) continue;
|
|
132
|
+
if (!children.has(row.ppid)) children.set(row.ppid, []);
|
|
133
|
+
children.get(row.ppid).push(row);
|
|
134
|
+
}
|
|
135
|
+
const descendants = [];
|
|
136
|
+
const skipped = [];
|
|
137
|
+
const seen = new Set([rootPid]);
|
|
138
|
+
const queue = [rootPid];
|
|
139
|
+
while (queue.length) {
|
|
140
|
+
const parent = queue.shift();
|
|
141
|
+
const parentRow = byPid.get(parent);
|
|
142
|
+
for (const row of children.get(parent) || []) {
|
|
143
|
+
if (seen.has(row.pid)) continue;
|
|
144
|
+
// Older than its "parent": the real parent died and its pid was reused.
|
|
145
|
+
if (parentRow && parentRow.started != null && row.started != null && row.started < parentRow.started) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
seen.add(row.pid);
|
|
149
|
+
if (isSharedServer(row.command)) {
|
|
150
|
+
skipped.push({ pid: row.pid, command: row.command });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
descendants.push({ pid: row.pid, command: row.command });
|
|
154
|
+
queue.push(row.pid);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { descendants, skipped };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Snapshot the descendants of a live process (see descendantsFromTable). */
|
|
161
|
+
async function listDescendants(rootPid, opts = {}) {
|
|
162
|
+
const table = await (opts.listProcesses || listProcesses)(opts);
|
|
163
|
+
return descendantsFromTable(table, rootPid);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function pidAlive(pid) {
|
|
167
|
+
try {
|
|
168
|
+
process.kill(pid, 0);
|
|
169
|
+
return true;
|
|
170
|
+
} catch (err) {
|
|
171
|
+
// EPERM: it exists but belongs to someone else.
|
|
172
|
+
return err.code === "EPERM";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function rootAlive(proc, pid) {
|
|
177
|
+
if (proc) return proc.exitCode === null && proc.signalCode === null;
|
|
178
|
+
return pidAlive(pid);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Signal the root and every descendant, wait up to `graceMs`, then SIGKILL
|
|
183
|
+
* whatever is left — including anything the tree started in the meantime —
|
|
184
|
+
* and wait up to `forceWaitMs` for it to go.
|
|
185
|
+
*
|
|
186
|
+
* On Windows every signal is TerminateProcess, so the grace period only
|
|
187
|
+
* matters for processes that are already exiting.
|
|
188
|
+
*
|
|
189
|
+
* @param {import('child_process').ChildProcess|number} target
|
|
190
|
+
* Prefer the ChildProcess: its exitCode is set before the pid can be
|
|
191
|
+
* reused, so a finished child is never signalled.
|
|
192
|
+
* @param {object} [opts]
|
|
193
|
+
* @param {{pid:number, command:string}[]} [opts.knownDescendants]
|
|
194
|
+
* Descendants snapshotted earlier, while the root was still alive. Needed
|
|
195
|
+
* on Linux and macOS when the root may already have exited: its children
|
|
196
|
+
* are then re-parented and can no longer be found from it. Each is killed
|
|
197
|
+
* only while its command line still matches, so a reused pid is spared.
|
|
198
|
+
* @returns {Promise<{ killed: number[], survivors: number[] }>}
|
|
199
|
+
*/
|
|
200
|
+
async function killProcessTree(target, opts = {}) {
|
|
201
|
+
const {
|
|
202
|
+
graceMs = DEFAULT_GRACE_MS,
|
|
203
|
+
forceWaitMs = DEFAULT_FORCE_WAIT_MS,
|
|
204
|
+
knownDescendants = [],
|
|
205
|
+
log = console.log,
|
|
206
|
+
logError = console.error,
|
|
207
|
+
label = "process",
|
|
208
|
+
platform = process.platform,
|
|
209
|
+
} = opts;
|
|
210
|
+
const proc = target && typeof target === "object" ? target : null;
|
|
211
|
+
const pid = proc ? proc.pid : target;
|
|
212
|
+
if (!pid) return { killed: [], survivors: [] };
|
|
213
|
+
|
|
214
|
+
const readTable = () => (opts.listProcesses || listProcesses)(opts);
|
|
215
|
+
|
|
216
|
+
// pid -> command, for every non-root member we intend to stop.
|
|
217
|
+
const members = new Map();
|
|
218
|
+
const absorb = (table) => {
|
|
219
|
+
const live = new Map(table.map((r) => [r.pid, r.command]));
|
|
220
|
+
// A member that exited, or whose pid now runs something else, is no
|
|
221
|
+
// longer ours to signal.
|
|
222
|
+
for (const [memberPid, command] of [...members]) {
|
|
223
|
+
if (live.get(memberPid) !== command) members.delete(memberPid);
|
|
224
|
+
}
|
|
225
|
+
for (const d of knownDescendants) {
|
|
226
|
+
if (live.has(d.pid) && live.get(d.pid) === d.command) members.set(d.pid, d.command);
|
|
227
|
+
}
|
|
228
|
+
if (live.has(pid) && rootAlive(proc, pid)) {
|
|
229
|
+
for (const d of descendantsFromTable(table, pid).descendants) members.set(d.pid, d.command);
|
|
230
|
+
}
|
|
231
|
+
// Children of members that outlived their parent's handle.
|
|
232
|
+
for (const memberPid of [...members.keys()]) {
|
|
233
|
+
for (const d of descendantsFromTable(table, memberPid).descendants) members.set(d.pid, d.command);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
let tableError = null;
|
|
238
|
+
try {
|
|
239
|
+
absorb(await readTable());
|
|
240
|
+
} catch (err) {
|
|
241
|
+
tableError = err;
|
|
242
|
+
logError(
|
|
243
|
+
`[process-tree] ${label} ${pid}: ${err.message} — only the ${label} itself will be stopped; ` +
|
|
244
|
+
"any process it started may keep running. Check them with Task Manager or `ps`."
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const signal = (p, sig) => {
|
|
249
|
+
try {
|
|
250
|
+
if (p === pid && proc) {
|
|
251
|
+
if (rootAlive(proc, pid)) proc.kill(sig);
|
|
252
|
+
} else {
|
|
253
|
+
process.kill(p, sig);
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (err.code !== "ESRCH") {
|
|
258
|
+
logError(`[process-tree] ${sig} to pid ${p} (${label} ${pid}) failed: ${err.message}`);
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const alive = (p) => (p === pid ? rootAlive(proc, pid) : pidAlive(p));
|
|
264
|
+
const waitAllGone = async (pids, ms) => {
|
|
265
|
+
const deadline = Date.now() + ms;
|
|
266
|
+
while (Date.now() < deadline) {
|
|
267
|
+
if (!pids.some(alive)) return true;
|
|
268
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
269
|
+
}
|
|
270
|
+
return !pids.some(alive);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const all = () => [pid, ...members.keys()];
|
|
274
|
+
const killed = [];
|
|
275
|
+
for (const p of all()) {
|
|
276
|
+
if (alive(p) && signal(p, "SIGTERM")) killed.push(p);
|
|
277
|
+
}
|
|
278
|
+
if (killed.length === 0) return { killed, survivors: [] };
|
|
279
|
+
log(`[process-tree] SIGTERM sent to ${label} ${pid} and ${killed.length - (killed.includes(pid) ? 1 : 0)} descendant(s)`);
|
|
280
|
+
|
|
281
|
+
if (await waitAllGone(all(), graceMs)) return { killed, survivors: [] };
|
|
282
|
+
|
|
283
|
+
if (platform === "win32") {
|
|
284
|
+
// SIGTERM was already TerminateProcess, so a member still listed is one
|
|
285
|
+
// that could not be ended or a pid already reused: only the root, through
|
|
286
|
+
// its handle, is worth a second try. Skipping the re-read also keeps a
|
|
287
|
+
// slow PowerShell from eating the quit's time limit.
|
|
288
|
+
members.clear();
|
|
289
|
+
} else if (!tableError) {
|
|
290
|
+
try {
|
|
291
|
+
absorb(await readTable());
|
|
292
|
+
} catch (err) {
|
|
293
|
+
// Without a fresh table a member's pid may already belong to a stranger.
|
|
294
|
+
members.clear();
|
|
295
|
+
logError(
|
|
296
|
+
`[process-tree] ${label} ${pid}: re-reading the tree before SIGKILL failed (${err.message}); ` +
|
|
297
|
+
`only the ${label} itself gets SIGKILL — any process it started may keep running.`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const stubborn = all().filter(alive);
|
|
302
|
+
for (const p of stubborn) {
|
|
303
|
+
if (signal(p, "SIGKILL") && !killed.includes(p)) killed.push(p);
|
|
304
|
+
}
|
|
305
|
+
log(`[process-tree] SIGKILL sent to ${stubborn.length} process(es) of ${label} ${pid} that ignored SIGTERM`);
|
|
306
|
+
|
|
307
|
+
await waitAllGone(stubborn, forceWaitMs);
|
|
308
|
+
const survivors = stubborn.filter(alive);
|
|
309
|
+
if (survivors.length) {
|
|
310
|
+
logError(
|
|
311
|
+
`[process-tree] ${label} ${pid}: pid(s) ${survivors.join(", ")} still running ${forceWaitMs} ms after SIGKILL — ` +
|
|
312
|
+
"end them with Task Manager or `kill -9` before relaunching GAIA."
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return { killed, survivors };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = {
|
|
319
|
+
listTimeoutMs,
|
|
320
|
+
worstCaseKillMs,
|
|
321
|
+
killProcessTree,
|
|
322
|
+
listDescendants,
|
|
323
|
+
listProcesses,
|
|
324
|
+
descendantsFromTable,
|
|
325
|
+
isSharedServer,
|
|
326
|
+
parsePosixTable,
|
|
327
|
+
parseWindowsTable,
|
|
328
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Run the app's async cleanup exactly once on quit, and never let it hold
|
|
6
|
+
* the quit forever.
|
|
7
|
+
*
|
|
8
|
+
* Electron does not await before-quit/will-quit handlers, so will-quit
|
|
9
|
+
* cancels the quit, runs the cleanup, and quits again. A second quit while
|
|
10
|
+
* the cleanup runs (tray Quit then Cmd+Q, or a fatal error mid-quit) must
|
|
11
|
+
* wait for that same cleanup: starting another one let the second finish
|
|
12
|
+
* first and end the app while the first was still stopping the backend.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Longest the quit waits for cleanup before going ahead without it (ms).
|
|
19
|
+
* Covers the slowest agent stop: two 20 s Windows process-table reads, the
|
|
20
|
+
* agent's shutdown wait and the kill grace.
|
|
21
|
+
*/
|
|
22
|
+
const CLEANUP_TIMEOUT_MS = 50000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} deps
|
|
26
|
+
* @param {Electron.App} deps.app
|
|
27
|
+
* @param {() => Promise<void>} deps.cleanup
|
|
28
|
+
* @param {number} [deps.timeoutMs]
|
|
29
|
+
* @param {{log: Function, error: Function}} [deps.logger]
|
|
30
|
+
* @returns {{ readonly done: boolean, readonly running: Promise<void>|null }}
|
|
31
|
+
*/
|
|
32
|
+
function installQuitCleanup({ app, cleanup, timeoutMs = CLEANUP_TIMEOUT_MS, logger = console }) {
|
|
33
|
+
let done = false;
|
|
34
|
+
let running = null;
|
|
35
|
+
|
|
36
|
+
app.on("will-quit", (event) => {
|
|
37
|
+
if (done) return;
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
if (running) {
|
|
40
|
+
logger.log("[main] will-quit: cleanup already running; quitting when it finishes");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
logger.log("[main] will-quit: performing async cleanup...");
|
|
44
|
+
|
|
45
|
+
let timer = null;
|
|
46
|
+
const deadline = new Promise((resolve) => {
|
|
47
|
+
timer = setTimeout(() => {
|
|
48
|
+
logger.error(
|
|
49
|
+
`[main] Cleanup did not finish within ${timeoutMs} ms; quitting anyway. ` +
|
|
50
|
+
"A backend or agent process may still be running — check Task Manager or `ps`."
|
|
51
|
+
);
|
|
52
|
+
resolve();
|
|
53
|
+
}, timeoutMs);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
running = Promise.race([
|
|
57
|
+
Promise.resolve()
|
|
58
|
+
.then(cleanup)
|
|
59
|
+
.catch((err) => {
|
|
60
|
+
logger.error("[main] Cleanup error:", (err && err.stack) || err);
|
|
61
|
+
}),
|
|
62
|
+
deadline,
|
|
63
|
+
])
|
|
64
|
+
// Re-quit on a later task: Electron drops a quit() that arrives while it
|
|
65
|
+
// is still unwinding the will-quit it just prevented, which is what an
|
|
66
|
+
// instant cleanup (no backend left to stop) used to hit.
|
|
67
|
+
.then(() => new Promise((resolve) => setImmediate(resolve)))
|
|
68
|
+
.then(() => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
done = true;
|
|
71
|
+
logger.log("[main] Cleanup complete, quitting...");
|
|
72
|
+
app.quit();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
get done() {
|
|
78
|
+
return done;
|
|
79
|
+
},
|
|
80
|
+
get running() {
|
|
81
|
+
return running;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { installQuitCleanup, CLEANUP_TIMEOUT_MS };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ask before quitting while an answer is being written or a document is
|
|
6
|
+
* being indexed: quitting stops both, and the answer is lost.
|
|
7
|
+
*
|
|
8
|
+
* The backend is asked at quit time (`GET /api/system/work-in-progress`),
|
|
9
|
+
* so nothing polls while GAIA is idle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const CHECK_TIMEOUT_MS = 3000;
|
|
13
|
+
// After the quit question fails, how long a second quit goes through without it.
|
|
14
|
+
const QUIT_ANYWAY_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
/** Read JSON from the GAIA service; throws an error that says what failed. */
|
|
17
|
+
async function backendJson(
|
|
18
|
+
port,
|
|
19
|
+
route,
|
|
20
|
+
{ method = "GET", body, timeoutMs = CHECK_TIMEOUT_MS, fetchImpl = fetch } = {}
|
|
21
|
+
) {
|
|
22
|
+
const url = `http://127.0.0.1:${port}${route}`;
|
|
23
|
+
let resp;
|
|
24
|
+
try {
|
|
25
|
+
const headers = { "X-Gaia-UI": "1" };
|
|
26
|
+
// The service refuses a state-changing request without X-Gaia-UI (ui/security.py).
|
|
27
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
28
|
+
resp = await fetchImpl(url, {
|
|
29
|
+
method,
|
|
30
|
+
headers,
|
|
31
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
32
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
33
|
+
});
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new Error(`the GAIA service at ${url} did not answer (${err.message})`, { cause: err });
|
|
36
|
+
}
|
|
37
|
+
let reply = null;
|
|
38
|
+
try {
|
|
39
|
+
reply = await resp.json();
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw new Error(`the GAIA service sent an unreadable reply from ${route} (HTTP ${resp.status}): ${err.message}`);
|
|
42
|
+
}
|
|
43
|
+
if (!resp.ok) {
|
|
44
|
+
const detail = reply && typeof reply.detail === "string" ? reply.detail : `HTTP ${resp.status}`;
|
|
45
|
+
const error = new Error(detail);
|
|
46
|
+
error.status = resp.status;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return reply;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fetchWorkInProgress(port, opts) {
|
|
53
|
+
return backendJson(port, "/api/system/work-in-progress", opts);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function quoteList(names) {
|
|
57
|
+
const quoted = names.map((n) => `'${n}'`);
|
|
58
|
+
if (quoted.length <= 2) return quoted.join(" and ");
|
|
59
|
+
return `${quoted.slice(0, 2).join(", ")} and ${quoted.length - 2} more`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What is about to happen, in the dialog's words. */
|
|
63
|
+
const QUIT = Object.freeze({ title: "Quit GAIA?", verb: "Quitting", confirm: "Quit anyway" });
|
|
64
|
+
const RESTART_FOR_UPDATE = Object.freeze({
|
|
65
|
+
title: "Restart to update?",
|
|
66
|
+
verb: "Restarting",
|
|
67
|
+
confirm: "Restart anyway",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The warning for this work, or null when nothing would be lost.
|
|
72
|
+
*
|
|
73
|
+
* @param {{answers: Array<{title: string|null}>, indexing: Array<{filename: string|null}>}} work
|
|
74
|
+
* @param {string} [verb] "Quitting" or "Restarting".
|
|
75
|
+
*/
|
|
76
|
+
function describeWork(work, verb = QUIT.verb) {
|
|
77
|
+
const answers = work.answers.map((a) => a.title || "a chat");
|
|
78
|
+
const files = work.indexing.map((d) => d.filename || "a document");
|
|
79
|
+
if (answers.length === 0 && files.length === 0) return null;
|
|
80
|
+
|
|
81
|
+
const lines = [];
|
|
82
|
+
if (answers.length === 1) {
|
|
83
|
+
lines.push(`${verb} stops the answer in ${quoteList(answers)}, and that answer will be lost.`);
|
|
84
|
+
} else if (answers.length > 1) {
|
|
85
|
+
lines.push(`${verb} stops the answers in ${quoteList(answers)}, and those answers will be lost.`);
|
|
86
|
+
}
|
|
87
|
+
if (files.length > 0) {
|
|
88
|
+
lines.push(`Indexing ${quoteList(files)} stops too; add ${files.length === 1 ? "it" : "them"} again later.`);
|
|
89
|
+
}
|
|
90
|
+
let message;
|
|
91
|
+
if (files.length === 0) {
|
|
92
|
+
message = answers.length === 1 ? "GAIA is still writing an answer." : `GAIA is still writing ${answers.length} answers.`;
|
|
93
|
+
} else if (answers.length === 0) {
|
|
94
|
+
message = "GAIA is still indexing documents.";
|
|
95
|
+
} else {
|
|
96
|
+
message = "GAIA is still working.";
|
|
97
|
+
}
|
|
98
|
+
return { message, detail: lines.join("\n\n") };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether the quit (or update restart) should go ahead. Asks only when work
|
|
103
|
+
* would be lost, or when the check itself failed and the answer is unknown.
|
|
104
|
+
*
|
|
105
|
+
* @param {{getWork: () => Promise<object>, showMessageBox: (opts: object) => Promise<{response: number}>,
|
|
106
|
+
* action?: typeof QUIT}} deps `action` is QUIT (the default) or RESTART_FOR_UPDATE.
|
|
107
|
+
* @returns {Promise<boolean>}
|
|
108
|
+
*/
|
|
109
|
+
async function confirmQuit({ getWork, showMessageBox, action = QUIT }) {
|
|
110
|
+
let warning;
|
|
111
|
+
try {
|
|
112
|
+
warning = describeWork(await getWork(), action.verb);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
warning = {
|
|
115
|
+
message: "GAIA couldn't check whether it is still working.",
|
|
116
|
+
detail: `${err.message}\n\nIf an answer is being written, ${action.verb.toLowerCase()} stops it and the answer will be lost.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (!warning) return true;
|
|
120
|
+
const { response } = await showMessageBox({
|
|
121
|
+
type: "warning",
|
|
122
|
+
title: action.title,
|
|
123
|
+
message: warning.message,
|
|
124
|
+
detail: warning.detail,
|
|
125
|
+
buttons: ["Keep running", action.confirm],
|
|
126
|
+
defaultId: 0,
|
|
127
|
+
cancelId: 0,
|
|
128
|
+
noLink: true,
|
|
129
|
+
});
|
|
130
|
+
return response === 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Hold every quit until `confirm` agrees, then quit for real.
|
|
135
|
+
*
|
|
136
|
+
* Electron's before-quit does not wait for async handlers, so the first quit
|
|
137
|
+
* is cancelled while the check runs and re-issued once it passes. Quits that
|
|
138
|
+
* must not ask (an update restart, an OS sign-out or shutdown) call `allow()`.
|
|
139
|
+
*
|
|
140
|
+
* @param {{app: Electron.App, updater: NodeJS.EventEmitter,
|
|
141
|
+
* shouldAsk: () => boolean, confirm: () => Promise<boolean>,
|
|
142
|
+
* onQuitting: () => void, onError: (err: Error) => void, now?: () => number}} deps
|
|
143
|
+
* `onError` should tell the user that quitting again within QUIT_ANYWAY_MS
|
|
144
|
+
* quits without asking.
|
|
145
|
+
* @returns {{readonly confirmed: boolean, allow(): void}}
|
|
146
|
+
*/
|
|
147
|
+
function installQuitGuard({ app, updater, shouldAsk, confirm, onQuitting, onError, now = Date.now }) {
|
|
148
|
+
let confirmed = false;
|
|
149
|
+
let inFlight = false;
|
|
150
|
+
// If the question itself can't be shown, a quit soon after goes through, so
|
|
151
|
+
// a broken dialog can never trap the user in GAIA; a later quit asks again.
|
|
152
|
+
let quitAnywayUntil = 0;
|
|
153
|
+
// The restart was already confirmed in the update dialog.
|
|
154
|
+
updater.on("before-quit-for-update", () => {
|
|
155
|
+
confirmed = true;
|
|
156
|
+
});
|
|
157
|
+
app.on("before-quit", (event) => {
|
|
158
|
+
if (!confirmed && now() >= quitAnywayUntil && shouldAsk()) {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
if (inFlight) return;
|
|
161
|
+
inFlight = true;
|
|
162
|
+
confirm()
|
|
163
|
+
.then((go) => {
|
|
164
|
+
inFlight = false;
|
|
165
|
+
if (!go) {
|
|
166
|
+
console.log("[quit-guard] Quit cancelled: work still running");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
confirmed = true;
|
|
170
|
+
app.quit();
|
|
171
|
+
})
|
|
172
|
+
.catch((err) => {
|
|
173
|
+
inFlight = false;
|
|
174
|
+
quitAnywayUntil = now() + QUIT_ANYWAY_MS;
|
|
175
|
+
onError(err);
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
confirmed = true;
|
|
180
|
+
onQuitting();
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
get confirmed() {
|
|
184
|
+
return confirmed;
|
|
185
|
+
},
|
|
186
|
+
allow() {
|
|
187
|
+
confirmed = true;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* What closing the window does: hide it to the tray (telling the user once),
|
|
194
|
+
* or, when closing quits, run the quit guard first and keep the window open
|
|
195
|
+
* until it agrees. An OS sign-out (Windows' session-end) never waits on it.
|
|
196
|
+
*
|
|
197
|
+
* @param {Electron.BrowserWindow} win
|
|
198
|
+
* @param {{app: Electron.App, guard: ReturnType<typeof installQuitGuard>,
|
|
199
|
+
* getTray: () => (object|null), isQuitting: () => boolean, platform: string,
|
|
200
|
+
* reportNoticeError: (err: Error) => void}} deps
|
|
201
|
+
*/
|
|
202
|
+
function attachWindowGuards(win, { app, guard, getTray, isQuitting, platform, reportNoticeError }) {
|
|
203
|
+
win.on("close", (event) => {
|
|
204
|
+
if (isQuitting()) return;
|
|
205
|
+
const tray = getTray();
|
|
206
|
+
if (tray && tray.minimizeToTray) {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
win.hide();
|
|
209
|
+
console.log("[main] Window hidden to tray");
|
|
210
|
+
if (tray.closeNoticeDue) {
|
|
211
|
+
Promise.resolve()
|
|
212
|
+
.then(() => tray.showCloseNotice())
|
|
213
|
+
.then((quit) => {
|
|
214
|
+
if (quit) app.quit();
|
|
215
|
+
})
|
|
216
|
+
.catch(reportNoticeError);
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// macOS keeps the app running without a window, so only there does close just close.
|
|
221
|
+
if (platform !== "darwin" && !guard.confirmed) {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
app.quit();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
// Windows asks (query-session-end) before it ends the session (session-end).
|
|
227
|
+
win.on("query-session-end", () => guard.allow());
|
|
228
|
+
win.on("session-end", () => guard.allow());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
QUIT_ANYWAY_MS,
|
|
233
|
+
attachWindowGuards,
|
|
234
|
+
backendJson,
|
|
235
|
+
fetchWorkInProgress,
|
|
236
|
+
describeWork,
|
|
237
|
+
confirmQuit,
|
|
238
|
+
installQuitGuard,
|
|
239
|
+
QUIT,
|
|
240
|
+
RESTART_FOR_UPDATE,
|
|
241
|
+
CHECK_TIMEOUT_MS,
|
|
242
|
+
};
|