genexus-mcp 3.9.0 → 3.9.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.
@@ -818,42 +818,55 @@ function buildRuntimeStagingCheck() {
818
818
  const {
819
819
  resolveDefaultRuntimeRoot,
820
820
  listStagedRuntimes,
821
- getRunningGxMcpProcesses,
821
+ probeRunningGxMcpProcesses,
822
+ getRuntimeProcessUsage,
822
823
  classifyProcessRuntime,
823
824
  shouldStage
824
825
  } = require('../lib/runtime-stager');
825
826
 
826
827
  const runtimeRoot = resolveDefaultRuntimeRoot();
827
828
  const stagedList = listStagedRuntimes(runtimeRoot);
828
- const runningProcs = getRunningGxMcpProcesses();
829
+ const processProbe = probeRunningGxMcpProcesses();
830
+ const runningProcs = processProbe.processes || [];
831
+ if (processProbe.ok === false) {
832
+ return {
833
+ id: 'runtime_staging',
834
+ status: 'warn',
835
+ detail: `Runtime process inspection was incomplete; GC is fail-closed. ${processProbe.error || 'The process probe did not return executable paths.'}`
836
+ };
837
+ }
829
838
  const npxProcs = runningProcs.filter((p) => classifyProcessRuntime(p.exePath, runtimeRoot) === 'npx-cache');
830
839
  const stagedProcs = runningProcs.filter((p) => classifyProcessRuntime(p.exePath, runtimeRoot) === 'staged');
840
+ const inUseRuntimes = getRuntimeProcessUsage(runtimeRoot, runningProcs);
841
+ const inUseDetail = inUseRuntimes.length > 0
842
+ ? ` Retained in-use runtime(s): ${inUseRuntimes.map((entry) => `${entry.name} (PIDs: ${entry.pids.join(', ')})`).join('; ')}.`
843
+ : '';
831
844
 
832
845
  if (npxProcs.length > 0) {
833
846
  return {
834
847
  id: 'runtime_staging',
835
848
  status: 'warn',
836
- detail: `${npxProcs.length} process(es) running directly from npx cache (PIDs: ${npxProcs.map((p) => p.pid).join(', ')}). Terminate them to prevent EBUSY during upgrades.`
849
+ detail: `${npxProcs.length} process(es) running directly from npx cache (PIDs: ${npxProcs.map((p) => p.pid).join(', ')}). Terminate them to prevent EBUSY during upgrades.${inUseDetail}`
837
850
  };
838
851
  }
839
852
  if (shouldStage()) {
840
853
  return {
841
854
  id: 'runtime_staging',
842
855
  status: 'pass',
843
- detail: `Staging active at ${runtimeRoot}. ${stagedList.length} staged version(s) found, ${stagedProcs.length} active process(es).`
856
+ detail: `Staging active at ${runtimeRoot}. ${stagedList.length} staged version(s) found, ${stagedProcs.length} active process(es).${inUseDetail}`
844
857
  };
845
858
  }
846
859
  if (process.env.GENEXUS_MCP_GATEWAY_EXE) {
847
860
  return {
848
861
  id: 'runtime_staging',
849
862
  status: 'pass',
850
- detail: `Fixed-path runtime override active via GENEXUS_MCP_GATEWAY_EXE (${process.env.GENEXUS_MCP_GATEWAY_EXE}). Staging bypassed.`
863
+ detail: `Fixed-path runtime override active via GENEXUS_MCP_GATEWAY_EXE (${process.env.GENEXUS_MCP_GATEWAY_EXE}). Staging bypassed.${inUseDetail}`
851
864
  };
852
865
  }
853
866
  return {
854
867
  id: 'runtime_staging',
855
868
  status: 'pass',
856
- detail: 'Development checkout active. Runtime staging bypassed.'
869
+ detail: `Development checkout active. Runtime staging bypassed.${inUseDetail}`
857
870
  };
858
871
  } catch {
859
872
  return {
package/cli/index.js CHANGED
@@ -455,6 +455,15 @@ async function launchGateway(passthroughArgs, options) {
455
455
  const { ensureStagedGateway } = require('./lib/runtime-stager');
456
456
  const staged = ensureStagedGateway();
457
457
  gatewayExePath = staged.gatewayExePath;
458
+ const cleanup = staged.cleanup;
459
+ if (cleanup && (cleanup.skipped?.length || cleanup.failed?.length || cleanup.processProbeAvailable === false)) {
460
+ const summary = [
461
+ cleanup.processProbeAvailable === false ? 'process probe unavailable' : null,
462
+ cleanup.skipped?.length ? `${cleanup.skipped.length} skipped` : null,
463
+ cleanup.failed?.length ? `${cleanup.failed.length} failed` : null
464
+ ].filter(Boolean).join(', ');
465
+ if (!options.quiet) launcherStderr.write(`[genexus-mcp] Runtime cleanup incomplete: ${summary}.\n`);
466
+ }
458
467
  } catch (stageErr) {
459
468
  if (!options.quiet) {
460
469
  launcherStderr.write(`[genexus-mcp] Warning: Runtime staging failed (${stageErr.message}), falling back to direct binary.\n`);
@@ -96,75 +96,353 @@ function verifyManifest(stagedDir, manifestPath) {
96
96
  }
97
97
  }
98
98
 
99
- function cleanOldRuntimes(runtimeRoot, currentTargetDir, keepCount = 2) {
99
+ function isPathWithin(candidatePath, rootPath) {
100
+ if (!candidatePath || !rootPath) return false;
101
+ const relative = path.relative(path.resolve(rootPath), path.resolve(candidatePath));
102
+ return relative === '' || (
103
+ relative !== '..'
104
+ && !relative.startsWith(`..${path.sep}`)
105
+ && !path.isAbsolute(relative)
106
+ );
107
+ }
108
+
109
+ function getRuntimeProcessUsage(runtimeRoot, processes, runtimeDirectories = null) {
110
+ const runtimes = Array.isArray(runtimeDirectories)
111
+ ? runtimeDirectories
112
+ : listStagedRuntimes(runtimeRoot);
113
+ const usage = new Map();
114
+
115
+ for (const runtime of runtimes) {
116
+ const pids = (Array.isArray(processes) ? processes : [])
117
+ .filter((proc) => proc && isPathWithin(proc.exePath, runtime.path))
118
+ .map((proc) => Number(proc.pid))
119
+ .filter((pid) => Number.isInteger(pid) && pid > 0)
120
+ .sort((a, b) => a - b);
121
+ if (pids.length > 0) {
122
+ usage.set(path.resolve(runtime.path).toLowerCase(), {
123
+ name: runtime.name,
124
+ path: runtime.path,
125
+ pids
126
+ });
127
+ }
128
+ }
129
+
130
+ return Array.from(usage.values()).sort((a, b) => a.name.localeCompare(b.name));
131
+ }
132
+
133
+ function createRuntimeCleanupResult() {
134
+ return {
135
+ deleted: [],
136
+ skipped: [],
137
+ failed: [],
138
+ processProbeAvailable: true
139
+ };
140
+ }
141
+
142
+ function readRuntimeProcessSnapshot(processProvider) {
143
+ let snapshot = processProvider();
144
+ if (snapshot && !Array.isArray(snapshot) && Array.isArray(snapshot.processes)) {
145
+ if (snapshot.ok === false) throw new Error(snapshot.error || 'runtime process probe failed');
146
+ snapshot = snapshot.processes;
147
+ }
148
+ if (!Array.isArray(snapshot)) throw new Error('runtime process probe returned no process list');
149
+ if (snapshot.some((proc) => !proc || !proc.exePath)) {
150
+ throw new Error('runtime process probe returned a process without an executable path');
151
+ }
152
+ return snapshot;
153
+ }
154
+
155
+ function processPidsUnder(directory, processes) {
156
+ return (Array.isArray(processes) ? processes : [])
157
+ .filter((proc) => proc && isPathWithin(proc.exePath, directory))
158
+ .map((proc) => Number(proc.pid))
159
+ .filter((pid) => Number.isInteger(pid) && pid > 0)
160
+ .sort((a, b) => a - b);
161
+ }
162
+
163
+ function processPidsUnderAny(directories, processes) {
164
+ const paths = Array.isArray(directories) ? directories.filter(Boolean) : [directories].filter(Boolean);
165
+ return [...new Set(paths.flatMap((directory) => processPidsUnder(directory, processes)))]
166
+ .sort((a, b) => a - b);
167
+ }
168
+
169
+ function originalPathForTombstone(tombstonePath) {
170
+ if (typeof tombstonePath !== 'string') return null;
171
+ const marker = tombstonePath.lastIndexOf('.deleting-');
172
+ return marker > 0 ? tombstonePath.slice(0, marker) : null;
173
+ }
174
+
175
+ function cleanupRuntimeDirectory(directory, options = {}) {
176
+ const result = createRuntimeCleanupResult();
177
+ if (!directory || !fs.existsSync(directory)) return result;
178
+
179
+ const processProvider = typeof options.getRunningProcesses === 'function'
180
+ ? options.getRunningProcesses
181
+ : probeRunningGxMcpProcesses;
182
+ const rmSync = typeof options.rmSync === 'function' ? options.rmSync : fs.rmSync;
183
+ const removeOptions = { recursive: true, force: true, maxRetries: 3, retryDelay: 100 };
184
+ const probePaths = [
185
+ directory,
186
+ ...(Array.isArray(options.additionalProbePaths) ? options.additionalProbePaths : [])
187
+ ].filter(Boolean);
188
+
189
+ let initialSnapshot;
100
190
  try {
101
- if (!fs.existsSync(runtimeRoot)) return;
102
- const entries = fs.readdirSync(runtimeRoot, { withFileTypes: true });
191
+ initialSnapshot = readRuntimeProcessSnapshot(processProvider);
192
+ } catch (err) {
193
+ result.processProbeAvailable = false;
194
+ result.skipped.push({
195
+ path: directory,
196
+ reason: 'process-probe-unavailable',
197
+ pids: [],
198
+ error: err && err.message ? err.message : String(err)
199
+ });
200
+ return result;
201
+ }
103
202
 
104
- // Clean up stale tmp directories older than 10 minutes
105
- const now = Date.now();
106
- for (const entry of entries) {
107
- if (entry.isDirectory() && entry.name.includes('.tmp-')) {
108
- const fullTmpPath = path.join(runtimeRoot, entry.name);
109
- try {
110
- const stat = fs.statSync(fullTmpPath);
111
- if (now - stat.mtimeMs > 10 * 60 * 1000) {
112
- fs.rmSync(fullTmpPath, { recursive: true, force: true });
113
- }
114
- } catch {
115
- // Ignore removal failures on locked tmp dirs
116
- }
117
- }
203
+ const initialPids = processPidsUnderAny(probePaths, initialSnapshot);
204
+ if (initialPids.length > 0) {
205
+ result.skipped.push({ path: directory, reason: 'in-use', pids: initialPids });
206
+ return result;
207
+ }
208
+
209
+ let finalSnapshot;
210
+ try {
211
+ finalSnapshot = readRuntimeProcessSnapshot(processProvider);
212
+ } catch (err) {
213
+ result.processProbeAvailable = false;
214
+ result.skipped.push({
215
+ path: directory,
216
+ reason: 'process-probe-unavailable-before-delete',
217
+ pids: [],
218
+ error: err && err.message ? err.message : String(err)
219
+ });
220
+ return result;
221
+ }
222
+
223
+ const finalPids = processPidsUnderAny(probePaths, finalSnapshot);
224
+ if (finalPids.length > 0) {
225
+ result.skipped.push({ path: directory, reason: 'in-use-before-delete', pids: finalPids });
226
+ return result;
227
+ }
228
+
229
+ try {
230
+ rmSync(directory, removeOptions);
231
+ result.deleted.push(directory);
232
+ } catch (err) {
233
+ result.failed.push({ path: directory, error: err && err.message ? err.message : String(err) });
234
+ }
235
+ return result;
236
+ }
237
+
238
+ function mergeRuntimeCleanupResults(target, additional) {
239
+ if (!additional) return target;
240
+ target.deleted.push(...additional.deleted);
241
+ target.skipped.push(...additional.skipped);
242
+ target.failed.push(...additional.failed);
243
+ target.processProbeAvailable = target.processProbeAvailable && additional.processProbeAvailable;
244
+ return target;
245
+ }
246
+
247
+ function cleanOldRuntimes(runtimeRoot, currentTargetDir, keepCount = 2, options = {}) {
248
+ const result = createRuntimeCleanupResult();
249
+
250
+ const processProvider = typeof options.getRunningProcesses === 'function'
251
+ ? options.getRunningProcesses
252
+ : probeRunningGxMcpProcesses;
253
+ const renameSync = typeof options.renameSync === 'function' ? options.renameSync : fs.renameSync;
254
+ const rmSync = typeof options.rmSync === 'function' ? options.rmSync : fs.rmSync;
255
+ const removeOptions = { recursive: true, force: true, maxRetries: 3, retryDelay: 100 };
256
+
257
+ const readProcessSnapshot = () => readRuntimeProcessSnapshot(processProvider);
258
+
259
+ const probeImmediatelyBeforeDelete = (directory) => {
260
+ try {
261
+ const snapshot = readProcessSnapshot();
262
+ return { ok: true, pids: processPidsUnder(directory, snapshot) };
263
+ } catch (err) {
264
+ return { ok: false, pids: [], error: err && err.message ? err.message : String(err) };
118
265
  }
266
+ };
119
267
 
120
- // Gather valid version directories
268
+ try {
269
+ if (!fs.existsSync(runtimeRoot)) return result;
270
+ const entries = fs.readdirSync(runtimeRoot, { withFileTypes: true });
271
+ const now = Date.now();
121
272
  const versionDirs = [];
273
+ const tmpDirs = [];
274
+ const tombstoneDirs = [];
275
+
122
276
  for (const entry of entries) {
123
- if (!entry.isDirectory() || entry.name.includes('.tmp-')) continue;
277
+ if (!entry.isDirectory()) continue;
124
278
  const fullPath = path.join(runtimeRoot, entry.name);
279
+ if (entry.name.includes('.tmp-')) {
280
+ tmpDirs.push(fullPath);
281
+ continue;
282
+ }
283
+ if (entry.name.includes('.deleting-')) {
284
+ tombstoneDirs.push(fullPath);
285
+ continue;
286
+ }
125
287
  try {
126
288
  const stat = fs.statSync(fullPath);
127
- versionDirs.push({
128
- name: entry.name,
129
- path: fullPath,
130
- mtimeMs: stat.mtimeMs
131
- });
289
+ versionDirs.push({ name: entry.name, path: fullPath, mtimeMs: stat.mtimeMs });
132
290
  } catch {
133
- // Ignore stat errors
291
+ // Ignore an entry that disappeared during enumeration.
134
292
  }
135
293
  }
136
294
 
137
- // Sort newest first
138
295
  versionDirs.sort((a, b) => b.mtimeMs - a.mtimeMs);
139
-
140
- // Keep current targetDir always, plus up to keepCount newest
141
296
  const normalizedCurrent = path.resolve(currentTargetDir).toLowerCase();
297
+ const keep = Number.isFinite(Number(keepCount)) ? Math.max(0, Number(keepCount)) : 2;
142
298
  let kept = 0;
143
299
  const toDelete = [];
144
-
145
300
  for (const vdir of versionDirs) {
146
- const normalized = path.resolve(vdir.path).toLowerCase();
147
- if (normalized === normalizedCurrent) {
148
- continue;
149
- }
150
- if (kept < keepCount) {
151
- kept++;
152
- } else {
153
- toDelete.push(vdir.path);
301
+ if (path.resolve(vdir.path).toLowerCase() === normalizedCurrent) continue;
302
+ if (kept < keep) kept++;
303
+ else toDelete.push(vdir.path);
304
+ }
305
+
306
+ const staleTmpDirs = tmpDirs.filter((dir) => {
307
+ try { return now - fs.statSync(dir).mtimeMs > 10 * 60 * 1000; } catch { return false; }
308
+ });
309
+ if (toDelete.length === 0 && staleTmpDirs.length === 0 && tombstoneDirs.length === 0) {
310
+ return result;
311
+ }
312
+
313
+ // Finish tombstones and stale staging directories with the same
314
+ // fail-closed process protection used by immediate staging cleanup.
315
+ for (const tombstone of tombstoneDirs) {
316
+ const originalPath = originalPathForTombstone(tombstone);
317
+ mergeRuntimeCleanupResults(result, cleanupRuntimeDirectory(tombstone, {
318
+ ...options,
319
+ additionalProbePaths: originalPath ? [originalPath] : []
320
+ }));
321
+ }
322
+ for (const tmpDir of staleTmpDirs) {
323
+ mergeRuntimeCleanupResults(result, cleanupRuntimeDirectory(tmpDir, options));
324
+ }
325
+
326
+ if (toDelete.length === 0) return result;
327
+ let processSnapshot;
328
+ try {
329
+ processSnapshot = readProcessSnapshot();
330
+ } catch (err) {
331
+ result.processProbeAvailable = false;
332
+ const error = err && err.message ? err.message : String(err);
333
+ for (const dir of toDelete) {
334
+ result.skipped.push({ path: dir, reason: 'process-probe-unavailable', pids: [], error });
154
335
  }
336
+ return result;
155
337
  }
156
338
 
339
+ const initialUsage = new Map(
340
+ getRuntimeProcessUsage(runtimeRoot, processSnapshot, versionDirs)
341
+ .map((entry) => [path.resolve(entry.path).toLowerCase(), entry])
342
+ );
343
+
157
344
  for (const dirToDelete of toDelete) {
345
+ let freshSnapshot;
158
346
  try {
159
- fs.rmSync(dirToDelete, { recursive: true, force: true });
160
- } catch {
161
- // EBUSY or EPERM means another running process has loaded DLLs from this runtime.
162
- // Keep it for later cleanup.
347
+ freshSnapshot = readProcessSnapshot();
348
+ } catch (err) {
349
+ result.skipped.push({
350
+ path: dirToDelete,
351
+ reason: 'process-probe-unavailable',
352
+ pids: [],
353
+ error: err && err.message ? err.message : String(err)
354
+ });
355
+ continue;
356
+ }
357
+
358
+ const freshUsage = getRuntimeProcessUsage(runtimeRoot, freshSnapshot, versionDirs)
359
+ .find((entry) => path.resolve(entry.path).toLowerCase() === path.resolve(dirToDelete).toLowerCase());
360
+ if (freshUsage || initialUsage.has(path.resolve(dirToDelete).toLowerCase())) {
361
+ const usage = freshUsage || initialUsage.get(path.resolve(dirToDelete).toLowerCase());
362
+ result.skipped.push({ path: dirToDelete, reason: 'in-use', pids: usage.pids });
363
+ continue;
364
+ }
365
+
366
+ // Rename first. The post-rename probe below closes the normal
367
+ // TOCTOU window; a process that appears while retiring is restored
368
+ // to its original name before any recursive removal is attempted.
369
+ const deletingPath = `${dirToDelete}.deleting-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
370
+ try {
371
+ renameSync(dirToDelete, deletingPath);
372
+ } catch (err) {
373
+ result.failed.push({ path: dirToDelete, error: err && err.message ? err.message : String(err) });
374
+ continue;
375
+ }
376
+
377
+ let postSnapshot;
378
+ try {
379
+ postSnapshot = readProcessSnapshot();
380
+ } catch (err) {
381
+ try { renameSync(deletingPath, dirToDelete); } catch { }
382
+ result.skipped.push({
383
+ path: dirToDelete,
384
+ reason: 'process-probe-unavailable-after-rename',
385
+ pids: [],
386
+ error: err && err.message ? err.message : String(err)
387
+ });
388
+ continue;
389
+ }
390
+ const postPids = [
391
+ ...processPidsUnder(dirToDelete, postSnapshot),
392
+ ...processPidsUnder(deletingPath, postSnapshot)
393
+ ].filter((pid, index, all) => all.indexOf(pid) === index).sort((a, b) => a - b);
394
+ if (postPids.length > 0) {
395
+ let restored = true;
396
+ try { renameSync(deletingPath, dirToDelete); } catch { restored = false; }
397
+ result.skipped.push({
398
+ path: dirToDelete,
399
+ reason: 'in-use-after-rename',
400
+ pids: postPids,
401
+ restored
402
+ });
403
+ continue;
404
+ }
405
+ const finalDeletingProbe = probeImmediatelyBeforeDelete(deletingPath);
406
+ if (!finalDeletingProbe.ok) {
407
+ let restored = true;
408
+ try { renameSync(deletingPath, dirToDelete); } catch { restored = false; }
409
+ result.skipped.push({
410
+ path: dirToDelete,
411
+ reason: 'process-probe-unavailable-before-delete',
412
+ pids: [],
413
+ error: finalDeletingProbe.error,
414
+ restored
415
+ });
416
+ continue;
417
+ }
418
+ if (finalDeletingProbe.pids.length > 0) {
419
+ let restored = true;
420
+ try { renameSync(deletingPath, dirToDelete); } catch { restored = false; }
421
+ result.skipped.push({
422
+ path: dirToDelete,
423
+ reason: 'in-use-before-delete',
424
+ pids: finalDeletingProbe.pids,
425
+ restored
426
+ });
427
+ continue;
428
+ }
429
+
430
+ try {
431
+ rmSync(deletingPath, removeOptions);
432
+ result.deleted.push(dirToDelete);
433
+ } catch (err) {
434
+ result.failed.push({
435
+ path: dirToDelete,
436
+ deletingPath,
437
+ error: err && err.message ? err.message : String(err)
438
+ });
163
439
  }
164
440
  }
165
- } catch {
166
- // Garbage collection is best-effort
441
+ } catch (err) {
442
+ result.failed.push({ path: runtimeRoot, error: err && err.message ? err.message : String(err) });
167
443
  }
444
+
445
+ return result;
168
446
  }
169
447
 
170
448
  function ensureStagedGateway(options = {}) {
@@ -177,7 +455,8 @@ function ensureStagedGateway(options = {}) {
177
455
  staged: false,
178
456
  gatewayExePath: process.env.GENEXUS_MCP_GATEWAY_EXE || defaultGatewayExe,
179
457
  runtimeDir: path.dirname(process.env.GENEXUS_MCP_GATEWAY_EXE || defaultGatewayExe),
180
- reason: process.env.GENEXUS_MCP_GATEWAY_EXE ? 'GENEXUS_MCP_GATEWAY_EXE' : (process.env.GENEXUS_MCP_NO_STAGING === '1' ? 'NO_STAGING' : 'checkout')
458
+ reason: process.env.GENEXUS_MCP_GATEWAY_EXE ? 'GENEXUS_MCP_GATEWAY_EXE' : (process.env.GENEXUS_MCP_NO_STAGING === '1' ? 'NO_STAGING' : 'checkout'),
459
+ cleanup: null
181
460
  };
182
461
  }
183
462
 
@@ -195,12 +474,13 @@ function ensureStagedGateway(options = {}) {
195
474
  const stagedGatewayExe = path.join(targetDir, 'GxMcp.Gateway.exe');
196
475
 
197
476
  if (fs.existsSync(stagedGatewayExe)) {
198
- cleanOldRuntimes(runtimeRoot, targetDir);
477
+ const cleanup = cleanOldRuntimes(runtimeRoot, targetDir, 2, options);
199
478
  return {
200
479
  staged: true,
201
480
  gatewayExePath: stagedGatewayExe,
202
481
  runtimeDir: targetDir,
203
- fresh: false
482
+ fresh: false,
483
+ cleanup
204
484
  };
205
485
  }
206
486
 
@@ -211,6 +491,7 @@ function ensureStagedGateway(options = {}) {
211
491
  fs.mkdirSync(runtimeRoot, { recursive: true });
212
492
  const tmpDir = `${targetDir}.tmp-${process.pid}-${Date.now()}`;
213
493
  fs.mkdirSync(tmpDir, { recursive: true });
494
+ let temporaryCleanup = null;
214
495
 
215
496
  try {
216
497
  fs.cpSync(publishDir, tmpDir, { recursive: true });
@@ -222,31 +503,45 @@ function ensureStagedGateway(options = {}) {
222
503
  } catch (renameErr) {
223
504
  // Concurrent launcher might have completed the rename first
224
505
  if (fs.existsSync(stagedGatewayExe)) {
225
- try {
226
- fs.rmSync(tmpDir, { recursive: true, force: true });
227
- } catch {
228
- // Ignore tmp cleanup error
229
- }
506
+ temporaryCleanup = cleanupRuntimeDirectory(tmpDir, options);
230
507
  } else {
231
508
  throw renameErr;
232
509
  }
233
510
  }
234
511
  } catch (err) {
235
- try {
236
- fs.rmSync(tmpDir, { recursive: true, force: true });
237
- } catch {
238
- // Ignore tmp cleanup error
512
+ const failure = err instanceof Error ? err : new Error(String(err));
513
+ const cleanup = cleanupRuntimeDirectory(tmpDir, options);
514
+ failure.runtimeCleanup = cleanup;
515
+ const notes = [];
516
+ if (cleanup.skipped.length > 0) {
517
+ const reasons = cleanup.skipped
518
+ .map((entry) => `${entry.reason}${entry.pids?.length ? ` (PIDs: ${entry.pids.join(', ')})` : ''}`)
519
+ .join(', ');
520
+ notes.push(`skipped ${cleanup.skipped.length} path(s): ${reasons}`);
521
+ }
522
+ if (cleanup.failed.length > 0) {
523
+ notes.push(`failed ${cleanup.failed.length} path(s)`);
524
+ }
525
+ if (!cleanup.processProbeAvailable) {
526
+ notes.push('process probe unavailable');
527
+ }
528
+ if (notes.length > 0) {
529
+ failure.message = `${failure.message} Runtime cleanup ${notes.join('; ')}.`;
239
530
  }
240
- throw err;
531
+ throw failure;
241
532
  }
242
533
 
243
- cleanOldRuntimes(runtimeRoot, targetDir);
534
+ const cleanup = mergeRuntimeCleanupResults(
535
+ cleanOldRuntimes(runtimeRoot, targetDir, 2, options),
536
+ temporaryCleanup
537
+ );
244
538
 
245
539
  return {
246
540
  staged: true,
247
541
  gatewayExePath: stagedGatewayExe,
248
542
  runtimeDir: targetDir,
249
- fresh: true
543
+ fresh: true,
544
+ cleanup
250
545
  };
251
546
  }
252
547
 
@@ -255,7 +550,7 @@ function listStagedRuntimes(runtimeRoot = resolveDefaultRuntimeRoot()) {
255
550
  try {
256
551
  const entries = fs.readdirSync(runtimeRoot, { withFileTypes: true });
257
552
  return entries
258
- .filter((e) => e.isDirectory() && !e.name.includes('.tmp-'))
553
+ .filter((e) => e.isDirectory() && !e.name.includes('.tmp-') && !e.name.includes('.deleting-'))
259
554
  .map((e) => {
260
555
  const dirPath = path.join(runtimeRoot, e.name);
261
556
  let stat = null;
@@ -276,10 +571,14 @@ function listStagedRuntimes(runtimeRoot = resolveDefaultRuntimeRoot()) {
276
571
  }
277
572
  }
278
573
 
279
- function getRunningGxMcpProcesses() {
280
- if (process.platform !== 'win32') return [];
574
+ function probeRunningGxMcpProcesses() {
575
+ if (process.platform !== 'win32') {
576
+ return { ok: true, processes: [] };
577
+ }
281
578
  try {
282
- // Query CIM Win32_Process for GxMcp processes
579
+ // Query CIM Win32_Process for GxMcp processes. Keep the probe result
580
+ // distinguishable from a successful empty result: GC must fail closed
581
+ // when Windows denies the query.
283
582
  const output = execFileSync('powershell.exe', [
284
583
  '-NoProfile',
285
584
  '-NonInteractive',
@@ -287,31 +586,43 @@ function getRunningGxMcpProcesses() {
287
586
  `Get-CimInstance Win32_Process -Filter "name like 'GxMcp%'" | Select-Object ProcessId, ExecutablePath, CommandLine | ConvertTo-Json -Compress`
288
587
  ], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
289
588
 
290
- if (!output) return [];
589
+ if (!output) return { ok: true, processes: [] };
291
590
  let data;
292
591
  try {
293
592
  data = JSON.parse(output);
294
- } catch {
295
- return [];
593
+ } catch (err) {
594
+ return { ok: false, processes: [], error: `invalid process probe JSON: ${err.message}` };
296
595
  }
297
596
  const rows = Array.isArray(data) ? data : [data];
298
- return rows.map((r) => ({
597
+ const processes = rows.filter(Boolean).map((r) => ({
299
598
  pid: r.ProcessId,
300
599
  exePath: r.ExecutablePath || '',
301
600
  commandLine: r.CommandLine || ''
302
601
  }));
303
- } catch {
304
- return [];
602
+ const unresolved = processes.filter((proc) => !proc.exePath);
603
+ if (unresolved.length > 0) {
604
+ return {
605
+ ok: false,
606
+ processes,
607
+ error: `${unresolved.length} GxMcp process record(s) had no executable path; runtime GC is fail-closed.`
608
+ };
609
+ }
610
+ return { ok: true, processes };
611
+ } catch (err) {
612
+ return { ok: false, processes: [], error: err && err.message ? err.message : String(err) };
305
613
  }
306
614
  }
307
615
 
616
+ function getRunningGxMcpProcesses() {
617
+ return probeRunningGxMcpProcesses().processes;
618
+ }
619
+
308
620
  function classifyProcessRuntime(exePath, runtimeRoot = resolveDefaultRuntimeRoot()) {
309
621
  if (!exePath) return 'unknown';
310
- const norm = path.resolve(exePath).toLowerCase();
311
- const normRuntime = path.resolve(runtimeRoot).toLowerCase();
312
- if (norm.startsWith(normRuntime)) {
622
+ if (isPathWithin(exePath, runtimeRoot)) {
313
623
  return 'staged';
314
624
  }
625
+ const norm = path.resolve(exePath).toLowerCase();
315
626
  if (norm.includes('npm-cache') || norm.includes('_npx') || norm.includes('node_modules')) {
316
627
  return 'npx-cache';
317
628
  }
@@ -328,5 +639,7 @@ module.exports = {
328
639
  ensureStagedGateway,
329
640
  listStagedRuntimes,
330
641
  getRunningGxMcpProcesses,
642
+ probeRunningGxMcpProcesses,
643
+ getRuntimeProcessUsage,
331
644
  classifyProcessRuntime
332
645
  };