nexus-agentd 0.4.0 → 0.4.1

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.
@@ -1,12 +1,16 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  const DEFAULT_GRACE_MS = 2_000;
3
3
  const FORCE_KILL_WAIT_MS = 1_000;
4
- // Enumerating costs about a second on an idle machine and considerably more
5
- // when the gateway is busy. The sweep runs on every teardown — including a
6
- // failed ACP startup, which callers bound at five seconds — so it is capped
7
- // short: an incomplete sweep beats a teardown that never returns.
8
- const PROCESS_TABLE_TIMEOUT_MS = 1_200;
4
+ // Enumerating the process table costs about 1.3s on an idle machine (roughly
5
+ // 0.4s of PowerShell startup plus the CIM query) and more when the gateway is
6
+ // busy. The previous 1.2s cap sat just below that, so the sweep timed out and
7
+ // silently found nothing; leave headroom for the query to actually finish.
8
+ const PROCESS_TABLE_TIMEOUT_MS = 2_500;
9
9
  const PROCESS_TABLE_TTL_MS = 1_000;
10
+ // Ceiling for one orphan sweep. The sweep runs on every teardown — including a
11
+ // failed ACP startup, which callers bound at five seconds — so it must stay
12
+ // bounded: an incomplete sweep beats a teardown that never returns.
13
+ const ORPHAN_SWEEP_BUDGET_MS = 3_000;
10
14
  // Windows reports a killed process as gone from `tasklist` slightly before the
11
15
  // kernel closes the handles it held, so callers that delete the workspace or
12
16
  // temp directory right after a teardown can still see EBUSY.
@@ -22,6 +26,11 @@ export async function terminateProcessTree(child, options = {}) {
22
26
  // never handle: the graceful pass always fails and burns the whole
23
27
  // grace period before the forced retry. Force the tree kill directly.
24
28
  if (!hasExited(child)) {
29
+ // Enumerating the process table is the slowest step of a Windows
30
+ // teardown and does not depend on the leader being gone, so start
31
+ // it alongside the kill instead of after it. The snapshot then
32
+ // predates the tree kill, which the liveness filter below handles.
33
+ const table = windowsProcessTable();
25
34
  await taskkill(pid, true);
26
35
  if (!(await waitForExit(child, Math.max(graceMs, FORCE_KILL_WAIT_MS)))) {
27
36
  // A forced kill can still lose the race on a loaded machine,
@@ -29,13 +38,17 @@ export async function terminateProcessTree(child, options = {}) {
29
38
  await taskkill(pid, true);
30
39
  await waitForExit(child, FORCE_KILL_WAIT_MS);
31
40
  }
41
+ // A forced `taskkill /T` walks the tree as it exists at that
42
+ // instant, so a child spawned moments before the leader died
43
+ // survives as an orphan holding ports, file locks and inherited
44
+ // pipes. Sweep by parent id for whatever the tree kill missed.
45
+ await terminateOrphanedDescendants(pid, ORPHAN_SWEEP_BUDGET_MS, table);
46
+ }
47
+ else {
48
+ // A leader that exited before we got here cannot be addressed by
49
+ // taskkill at all, so nothing has swept its descendants yet.
50
+ await terminateOrphanedDescendants(pid);
32
51
  }
33
- // A forced `taskkill /T` walks the tree as it exists at that instant,
34
- // so a child spawned moments before the leader died survives as an
35
- // orphan holding ports, file locks and inherited pipes — and a leader
36
- // that exited before we got here cannot be addressed by taskkill at
37
- // all. Sweep by parent id in both cases.
38
- await terminateOrphanedDescendants(pid);
39
52
  if (hasExited(child)) {
40
53
  await delay(PROCESS_EXIT_SETTLE_MS);
41
54
  return;
@@ -115,17 +128,31 @@ function taskkill(pid, force) {
115
128
  const args = ['/PID', String(pid), '/T'];
116
129
  if (force)
117
130
  args.push('/F');
118
- const killer = spawn('taskkill.exe', args, { stdio: 'ignore', windowsHide: true });
119
- const timer = setTimeout(() => {
120
- killer.kill();
121
- resolve();
122
- }, FORCE_KILL_WAIT_MS);
123
- const finish = () => {
131
+ let killer;
132
+ try {
133
+ killer = spawn('taskkill.exe', args, { stdio: 'ignore', windowsHide: true });
134
+ }
135
+ catch {
136
+ resolve(false);
137
+ return;
138
+ }
139
+ let settled = false;
140
+ const finish = (killed) => {
141
+ if (settled)
142
+ return;
143
+ settled = true;
124
144
  clearTimeout(timer);
125
- resolve();
145
+ resolve(killed);
126
146
  };
127
- killer.once('error', finish);
128
- killer.once('exit', finish);
147
+ const timer = setTimeout(() => {
148
+ try {
149
+ killer.kill();
150
+ }
151
+ catch { }
152
+ finish(false);
153
+ }, FORCE_KILL_WAIT_MS);
154
+ killer.once('error', () => finish(false));
155
+ killer.once('exit', (code) => finish(code === 0));
129
156
  });
130
157
  }
131
158
  /**
@@ -136,16 +163,37 @@ function taskkill(pid, force) {
136
163
  * after that, which is enough to find the survivors. Best-effort by design: an
137
164
  * unreadable process table just leaves the sweep incomplete.
138
165
  */
139
- async function terminateOrphanedDescendants(pid) {
140
- const doomed = collectDescendants(await windowsProcessTable(), pid);
166
+ async function terminateOrphanedDescendants(pid, budgetMs = ORPHAN_SWEEP_BUDGET_MS, table) {
167
+ const deadline = Date.now() + budgetMs;
168
+ const snapshot = await (table ?? windowsProcessTable());
169
+ // The snapshot may predate a caller's tree kill, and each taskkill costs
170
+ // the better part of a second, so only ever aim at processes still alive.
171
+ const doomed = collectDescendants(snapshot, pid).filter((target) => processAlive(target));
141
172
  if (!doomed.length)
142
173
  return;
143
- for (const target of doomed)
144
- await taskkill(target, true);
145
- // Killing a parent can strand its own children, so rescan for what is left.
146
- const remaining = collectDescendants(await windowsProcessTable(true), pid);
147
- for (const survivor of remaining)
148
- await taskkill(survivor, true);
174
+ // `collectDescendants` already returns the whole descendant tree, so kill
175
+ // them together rather than paying the taskkill cost once per process.
176
+ await Promise.all(doomed.map((target) => taskkill(target, true)));
177
+ // Killing a parent can strand its own children, but the first pass covered
178
+ // the entire tree, so a rescan only pays off when one of the targets is
179
+ // still alive. Each rescan costs another full enumeration.
180
+ if (Date.now() >= deadline)
181
+ return;
182
+ if (!doomed.some((target) => processAlive(target)))
183
+ return;
184
+ const remaining = collectDescendants(await windowsProcessTable(true), pid)
185
+ .filter((survivor) => processAlive(survivor));
186
+ await Promise.all(remaining.map((survivor) => taskkill(survivor, true)));
187
+ }
188
+ function processAlive(pid) {
189
+ try {
190
+ process.kill(pid, 0);
191
+ return true;
192
+ }
193
+ catch (error) {
194
+ // EPERM means the process exists but belongs to someone else.
195
+ return error.code === 'EPERM';
196
+ }
149
197
  }
150
198
  function collectDescendants(table, root) {
151
199
  if (!table)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-agentd",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Local Nexus Gateway daemon with an HTTP/SSE API and WebUI for allowlisted ACP and remote A2A agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",