mcp-pacemaker 2.1.0 → 2.1.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +98 -2
  2. package/CONTRIBUTING.md +10 -3
  3. package/README.md +98 -760
  4. package/autostart/windows/legacy-access.ps1 +359 -0
  5. package/bin/account-upgrade-controller.mjs +1 -1
  6. package/bin/account-upgrade-worker.mjs +7 -0
  7. package/bin/cli-main.mjs +61 -5
  8. package/bin/dependency-lock.json +642 -0
  9. package/bin/legacy-access.mjs +73 -0
  10. package/bin/legacy-installation.mjs +75 -19
  11. package/bin/legacy-task.mjs +24 -0
  12. package/bin/legacy-upgrade.mjs +66 -10
  13. package/bin/managed-upgrade.mjs +102 -12
  14. package/bin/mcp-bridge.mjs +38 -20
  15. package/bin/prewarm.mjs +28 -6
  16. package/bin/service-control.mjs +10 -5
  17. package/bin/upgrade-capability.json +2 -1
  18. package/bin/upgrade-dependencies.mjs +67 -6
  19. package/bin/upgrade-package.mjs +2 -0
  20. package/bin/windows-legacy/LegacyProcessBroker.build.json +3 -3
  21. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  22. package/bin/windows-legacy/src/LegacyNative.cs +60 -11
  23. package/bin/windows-legacy/src/LegacyProcessBroker.cs +317 -197
  24. package/bin/windows-legacy-process.mjs +12 -5
  25. package/docs/account-upgrades.md +17 -4
  26. package/docs/compatibility.md +39 -0
  27. package/docs/configuration.md +146 -0
  28. package/docs/dashboards.md +125 -0
  29. package/docs/execution-context.md +11 -5
  30. package/docs/http-api.md +47 -0
  31. package/docs/install.md +52 -0
  32. package/docs/operations.md +99 -0
  33. package/docs/upgrades.md +324 -0
  34. package/package.json +10 -3
  35. package/tools/windows-legacy/README.md +118 -92
  36. package/tools/windows-legacy/inventory.mjs +8 -6
  37. package/ui/dist/third-party-manifest.json +1 -1
@@ -80,8 +80,8 @@ let PORT = Number(getArg('--port', '8791'));
80
80
  const HOST = getArg('--host', '127.0.0.1');
81
81
  let upgradeHeld = process.env.MCP_PACEMAKER_UPGRADE_PENDING === '1';
82
82
  let upgradeRequests = 0;
83
+ let upgradeCalls = 0;
83
84
  let upgradeTraffic = false;
84
- let upgradeUncertain = false;
85
85
  if (PORT === 0 &&
86
86
  (!process.connected || !process.env.MCP_PACEMAKER_MANAGED_INSTANCE || HOST !== '127.0.0.1')) {
87
87
  throw new Error('Port 0 requires a managed loopback startup with actual-port readiness.');
@@ -450,6 +450,13 @@ function handlePoolingChange(encodedName, req, res) {
450
450
  trace?.record('body-end', { bytes });
451
451
  clearTimeout(bodyTimer);
452
452
  if (rejected) return;
453
+ // A body that arrives after an upgrade closed admission must not start config work while
454
+ // the bridge is being stopped.
455
+ if (upgradeHeld ||
456
+ shuttingDown) {
457
+ reply(503, { error: 'Managed upgrade in progress; no change was submitted.' });
458
+ return;
459
+ }
453
460
  let body;
454
461
  let name;
455
462
  try {
@@ -2096,13 +2103,14 @@ const server = http.createServer((req, res) => {
2096
2103
  return;
2097
2104
  }
2098
2105
  if (!['api', 'ui', '.well-known'].includes(name)) {
2106
+ // A GET is a listening stream that lasts as long as its session; anything else is a call.
2107
+ const call = req.method !== 'GET';
2099
2108
  upgradeRequests++;
2109
+ if (call) upgradeCalls++;
2100
2110
  upgradeTraffic = true;
2101
- let finished = false;
2102
- res.once('finish', () => { finished = true; });
2103
2111
  res.once('close', () => {
2104
2112
  upgradeRequests--;
2105
- if (!finished) upgradeUncertain = true;
2113
+ if (call) upgradeCalls--;
2106
2114
  });
2107
2115
  }
2108
2116
  }
@@ -2208,16 +2216,26 @@ server.listen(PORT, HOST, () => {
2208
2216
 
2209
2217
  function upgradeState() {
2210
2218
  const pooling = poolingConfig.upgradeState();
2211
- const unsafe = upgradeUncertain ||
2212
- pooling.unsafe ||
2219
+ const unsafe = pooling.unsafe ||
2213
2220
  hasPoolingTransaction(CONFIG);
2221
+ const calls = upgradeCalls > 0 ||
2222
+ resuming.size > 0 ||
2223
+ tokenInFlight.size > 0;
2214
2224
  const busy = pooling.busy ||
2225
+ calls ||
2215
2226
  upgradeRequests > 0 ||
2216
2227
  sessions.size > 0 ||
2217
- httpSessions.size > 0 ||
2218
- resuming.size > 0 ||
2219
- tokenInFlight.size > 0;
2220
- return { unsafe, busy, configBusy: pooling.busy, held: upgradeHeld, traffic: upgradeTraffic, port: PORT, version: VERSION, instanceId };
2228
+ httpSessions.size > 0;
2229
+ return { unsafe, busy, calls, configBusy: pooling.busy, held: upgradeHeld, traffic: upgradeTraffic, port: PORT, version: VERSION, instanceId };
2230
+ }
2231
+
2232
+ // Quiesce waits for calls and config work, never for open sessions. A staged pooling batch
2233
+ // still commits on its own timer while admission is held.
2234
+ function draining() {
2235
+ const state = upgradeState();
2236
+ return state.unsafe ||
2237
+ state.configBusy ||
2238
+ state.calls;
2221
2239
  }
2222
2240
 
2223
2241
  let upgradeControlBusy = false;
@@ -2231,26 +2249,26 @@ process.on('message', async (message) => {
2231
2249
  try {
2232
2250
  if (message.action === 'quiesce') {
2233
2251
  upgradeHeld = true;
2252
+ // Admission is closed now. Calls in progress and config work get up to eight seconds;
2253
+ // calls still running at the restart end with their sessions. Config work that is still
2254
+ // unfinished blocks the upgrade, because an upgrade must never change servers.json.
2234
2255
  const deadline = Date.now() + Math.min(8000, Math.max(0, message.timeoutMs ?? 8000));
2235
- while (upgradeState().busy &&
2236
- !upgradeState().unsafe &&
2256
+ while (draining() &&
2237
2257
  Date.now() < deadline) {
2238
2258
  await new Promise(resolveWait => setTimeout(resolveWait, 25));
2239
2259
  }
2240
2260
  const state = upgradeState();
2241
- if (state.busy ||
2242
- state.unsafe) {
2243
- throw new Error('Active work or unresolved config transaction blocks upgrade. Settle work and retry; nothing was replayed.');
2261
+ if (state.unsafe ||
2262
+ state.configBusy) {
2263
+ throw new Error('Unfinished config work blocks upgrade. Let it finish and retry; nothing was replayed.');
2244
2264
  }
2245
2265
  } else if (message.action === 'activate' ||
2246
2266
  message.action === 'resume') {
2247
2267
  if (upgradeState().unsafe) throw new Error('Unresolved config transaction prevents admission.');
2248
2268
  if (message.action === 'activate') {
2249
- const files = new PoolingFiles(CONFIG);
2250
- const sessions = existsSync(RESUME_FILE) ? files.inspect(RESUME_FILE) : null;
2251
- if (!isDeepStrictEqual(files.inspect(CONFIG), message.expectedAuthority) ||
2252
- !isDeepStrictEqual(sessions, message.expectedSessionState)) {
2253
- throw new Error('Configuration or persisted session state changed before admission; the bridge remains held.');
2269
+ // Session records may change while the old version runs; only the config must match.
2270
+ if (!isDeepStrictEqual(new PoolingFiles(CONFIG).inspect(CONFIG), message.expectedAuthority)) {
2271
+ throw new Error('Configuration changed before admission; the bridge remains held.');
2254
2272
  }
2255
2273
  } else {
2256
2274
  await poolingConfig.reloadSaved('upgrade resumed');
package/bin/prewarm.mjs CHANGED
@@ -1,23 +1,45 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { request } from 'node:http';
2
3
  import { dirname, join } from 'node:path';
4
+ import { text as readText } from 'node:stream/consumers';
3
5
  import { fileURLToPath } from 'node:url';
4
6
  import { canonicalPort } from './cli-selection.mjs';
5
7
 
8
+ const REQUEST_TIMEOUT_MS = 10_000;
9
+
10
+ // node:http, not fetch: on Windows, Node 23, 24 before 24.20.0, 25, and 26 before 26.7.0 can abort
11
+ // with a libuv assertion when the CLI exits soon after fetch (nodejs/node#56645).
12
+ async function sendRequest(port, path, { method = 'GET', headers = {}, body } = {}) {
13
+ const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
14
+ try {
15
+ const response = await new Promise((resolve, reject) => {
16
+ const outgoing = request({ hostname: '127.0.0.1', port, path, method, headers, signal }, resolve);
17
+ outgoing.on('error', reject);
18
+ outgoing.end(body);
19
+ });
20
+ return { status: response.statusCode, text: await readText(response) };
21
+ } catch (error) {
22
+ if (signal.aborted) {
23
+ const advice = method === 'GET' ? '' : ' The change may still apply; reread settings before retrying.';
24
+ throw new Error(`Bridge on :${port} did not answer within ${REQUEST_TIMEOUT_MS / 1000}s.${advice}`);
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+
6
30
  async function jsonRequest(port, path, options = {}) {
7
- const response = await fetch(`http://127.0.0.1:${port}${path}`, {
8
- ...options,
9
- signal: AbortSignal.timeout(10_000),
10
- });
31
+ const response = await sendRequest(port, path, options);
11
32
  if (response.status === 401) {
12
33
  throw new Error('Admin nonce was rejected. Check --config points to the running bridge, then retry.');
13
34
  }
14
35
  let result;
15
36
  try {
16
- result = await response.json();
37
+ result = JSON.parse(response.text);
17
38
  } catch {
18
39
  throw new Error(`Bridge on :${port} returned a non-JSON response (${response.status})`);
19
40
  }
20
- if (!response.ok) {
41
+ const succeeded = response.status >= 200 && response.status <= 299;
42
+ if (!succeeded) {
21
43
  throw new Error(result.error || `Bridge on :${port} rejected the request (${response.status})`);
22
44
  }
23
45
  return { status: response.status, body: result };
@@ -138,10 +138,15 @@ export async function controlManagedService({ root, config, port }, action, expe
138
138
  id: record.id, token: record.token, action,
139
139
  expectedAuthority: expected.authority, expectedSessionState: expected.sessionState,
140
140
  });
141
- if (response.id !== record.id ||
142
- response.ok !== true ||
143
- response.instanceId !== record.instanceId ||
144
- response.version !== record.version ||
145
- response.port !== port) throw new Error(response.error || 'Managed bridge identity/admission could not be verified.');
141
+ const identified = response.id === record.id &&
142
+ response.instanceId === record.instanceId &&
143
+ response.version === record.version &&
144
+ response.port === port;
145
+ if (!identified ||
146
+ response.ok !== true) {
147
+ // Only a refusal from the identified bridge is definite; anything else leaves the outcome unknown.
148
+ throw Object.assign(new Error(response.error || 'Managed bridge identity/admission could not be verified.'),
149
+ { refused: identified && response.ok === false });
150
+ }
146
151
  return response;
147
152
  }
@@ -4,5 +4,6 @@
4
4
  "admission": true,
5
5
  "cli": true,
6
6
  "legacyRestartProtocol": 1,
7
- "accountWorkerProtocol": 1
7
+ "accountWorkerProtocol": 1,
8
+ "dependencyLock": 1
8
9
  }
@@ -1,6 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, lstatSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
3
+ import { existsSync, lstatSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
4
4
  import { join, relative } from 'node:path';
5
5
  import { isDeepStrictEqual, promisify } from 'node:util';
6
6
  import semver from 'semver';
@@ -8,6 +8,7 @@ import { npmCliPath } from './update-check.mjs';
8
8
  import { PoolingFiles } from './pooling-files.mjs';
9
9
 
10
10
  const execute = promisify(execFile);
11
+ const PUBLIC_REGISTRY = 'https://registry.npmjs.org/';
11
12
  const policies = [
12
13
  'registry', 'replace-registry-host', 'offline', 'prefer-offline', 'strict-ssl', 'ca', 'cafile',
13
14
  'proxy', 'https-proxy', 'noproxy', 'local-address', 'min-release-age', 'min-release-age-exclude',
@@ -47,9 +48,12 @@ export function validateDependencyLock(root) {
47
48
  if (!path.startsWith('node_modules/') ||
48
49
  path.split('/').some(part => !part || part === '.' || part === '..') ||
49
50
  entry.link ||
50
- semver.valid(entry.version) !== entry.version ||
51
- typeof entry.integrity !== 'string' ||
52
- !entry.integrity.startsWith('sha512-')) throw new Error('Unverifiable target dependency lock entry.');
51
+ semver.valid(entry.version) !== entry.version) throw new Error('Unverifiable target dependency lock entry.');
52
+ if (typeof entry.integrity !== 'string' ||
53
+ !entry.integrity.startsWith('sha512-')) {
54
+ const found = typeof entry.integrity === 'string' ? entry.integrity.split('-')[0] : 'no';
55
+ throw new Error(`Unverifiable target dependency lock entry: ${path}@${entry.version} has ${found} integrity from the registry; SHA-512 is required.`);
56
+ }
53
57
  const integrity = Buffer.from(entry.integrity.slice(7), 'base64');
54
58
  if (integrity.length !== 64 ||
55
59
  `sha512-${integrity.toString('base64')}` !== entry.integrity) throw new Error('Invalid dependency archive integrity.');
@@ -62,6 +66,51 @@ export function validateDependencyLock(root) {
62
66
  return lock;
63
67
  }
64
68
 
69
+ // From 2.1.1 a target ships the SHA-512 lock it was released and tested with. npm ci then
70
+ // verifies every archive against it, so registry metadata (SHA-1 on some mirrors) and newer
71
+ // in-range releases cannot change the staged dependency set.
72
+ export function bundledDependencyLock(root) {
73
+ const capabilityPath = join(root, 'bin', 'upgrade-capability.json');
74
+ if (!existsSync(capabilityPath)) return null;
75
+ const capability = JSON.parse(readFileSync(capabilityPath, 'utf8'));
76
+ if (capability.dependencyLock !== 1) return null;
77
+ const path = join(root, 'bin', 'dependency-lock.json');
78
+ const stat = existsSync(path) ? lstatSync(path) : null;
79
+ if (!stat?.isFile() ||
80
+ stat.size === 0) throw new Error('Target declares a bundled dependency lock but does not contain it.');
81
+ return readFileSync(path);
82
+ }
83
+
84
+ // npm points public-registry lock URLs at the configured registry itself, unless
85
+ // replace-registry-host is "never" or names only another host or URL prefix.
86
+ export function npmMapsPublicRegistry(setting) {
87
+ const value = String(setting ?? 'npmjs');
88
+ return ['npmjs', 'always'].includes(value) ||
89
+ value.includes('registry.npmjs.org');
90
+ }
91
+
92
+ // The bundled lock names the public registry. When npm will not map those URLs, point each
93
+ // entry at the registry that resolving it would have used: its scope's registry, else the
94
+ // configured one. Integrity covers the archive bytes, not the URL.
95
+ export function registryLock(bytes, registry, scopes = {}) {
96
+ const lock = JSON.parse(bytes.toString('utf8'));
97
+ let changed = false;
98
+ for (const [path, entry] of Object.entries(lock.packages ?? {})) {
99
+ const publicEntry = path &&
100
+ typeof entry.resolved === 'string' &&
101
+ entry.resolved.startsWith(PUBLIC_REGISTRY);
102
+ if (!publicEntry) continue;
103
+ const name = path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length);
104
+ const target = (name.startsWith('@') ? scopes[name.split('/')[0]] : undefined) ?? registry;
105
+ if (!target) continue;
106
+ const base = target.endsWith('/') ? target : `${target}/`;
107
+ if (base === PUBLIC_REGISTRY) continue;
108
+ entry.resolved = base + entry.resolved.slice(PUBLIC_REGISTRY.length);
109
+ changed = true;
110
+ }
111
+ return changed ? Buffer.from(`${JSON.stringify(lock, null, 2)}\n`) : bytes;
112
+ }
113
+
65
114
  export async function stageCliDependencies(root, { registry, cache, run = execute } = {}) {
66
115
  const cli = npmCliPath();
67
116
  const call = async (args, options = {}) => {
@@ -91,12 +140,19 @@ export async function stageCliDependencies(root, { registry, cache, run = execut
91
140
  const projectConfig = join(prefix, '.npmrc');
92
141
  const stagedConfig = join(root, '.npmrc');
93
142
  if (existsSync(stagedConfig)) throw new Error('Unexpected target npm configuration.');
143
+ const bundled = bundledDependencyLock(root);
144
+ if (bundled &&
145
+ existsSync(join(root, 'package-lock.json'))) throw new Error('Unexpected target dependency lock.');
146
+ const scopes = Object.fromEntries(Object.entries(configuration)
147
+ .filter(([key, value]) => typeof value === 'string' &&
148
+ /^@[^:/]+:registry$/.test(key))
149
+ .map(([key, value]) => [key.slice(0, -':registry'.length), value]));
150
+ const manifest = readFileSync(join(root, 'package.json'));
94
151
  if (existsSync(projectConfig)) {
95
152
  const files = new PoolingFiles(projectConfig);
96
153
  const identity = files.inspect(projectConfig);
97
154
  files.stage(stagedConfig, projectConfig, identity, readFileSync(projectConfig));
98
155
  }
99
- const manifest = readFileSync(join(root, 'package.json'));
100
156
  const args = ['--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund', '--bin-links=false',
101
157
  '--cache', cache, '--registry', registry];
102
158
  try {
@@ -108,7 +164,12 @@ export async function stageCliDependencies(root, { registry, cache, run = execut
108
164
  throw new Error('Effective npm policy changed in dependency staging; no dependency resolution was attempted.');
109
165
  }
110
166
  }
111
- await call(['install', '--package-lock-only', ...args], { cwd: root, env });
167
+ if (bundled) {
168
+ const staged = npmMapsPublicRegistry(configuration['replace-registry-host'])
169
+ ? bundled
170
+ : registryLock(bundled, registry, scopes);
171
+ writeFileSync(join(root, 'package-lock.json'), staged, { flag: 'wx' });
172
+ } else await call(['install', '--package-lock-only', ...args], { cwd: root, env });
112
173
  validateDependencyLock(root);
113
174
  const lock = readFileSync(join(root, 'package-lock.json'));
114
175
  await call(['ci', ...args], { cwd: root, env });
@@ -97,6 +97,8 @@ export function unpackUpgrade(bytes, version, destination, nodeVersion = process
97
97
  capability.portZero !== true ||
98
98
  capability.admission !== true ||
99
99
  capability.cli !== true) throw new Error('Target does not support managed readiness/admission/port allocation/CLI dispatch.');
100
+ if (capability.dependencyLock === 1 &&
101
+ !files.get('bin/dependency-lock.json')?.bytes.length) throw new Error('Target declares a bundled dependency lock but does not contain it.');
100
102
  for (const file of files.values()) {
101
103
  if (![0o644, 0o755].includes(file.mode)) throw new Error('Unexpected package file mode.');
102
104
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "binary": "LegacyProcessBroker.exe",
4
- "binarySha256": "0d4579af8e4e6ec2c762d924a68586ab719589b3dd4da22e916fb17ee1b6da0b",
4
+ "binarySha256": "64323058e9c0437409522b6cca6d1a47c32e125980b83a07021932d1d5a317ef",
5
5
  "inputs": {
6
6
  "targetFramework": ".NETFramework,Version=v4.6.2",
7
7
  "platform": "AnyCPU",
@@ -27,8 +27,8 @@
27
27
  "sourceEncoding": "UTF-8 without BOM, LF line endings",
28
28
  "sourceSha256": {
29
29
  "AssemblyInfo.cs": "09f672607b66aff39c457899d270688972e440aafaa43b2cb56c1a2367576356",
30
- "LegacyNative.cs": "274b9494d249cc4eb3710181047e094f17bf23b0d9301d777be0d517aebb6710",
31
- "LegacyProcessBroker.cs": "9d086ab312398aee9462ba85b85e590b551a33e91bd22ecdf21168194ec1a9c4"
30
+ "LegacyNative.cs": "28f496cba02ebfbbe07102bc70133198b40846838c0189e0bfcdb91d2e0a95c6",
31
+ "LegacyProcessBroker.cs": "2edd5f77787498d5323185e00b663084e140c9aec6ebca3921d66078bbc66c2a"
32
32
  },
33
33
  "buildScriptSha256": "2ffa31155f6aa8f6b4239facb0f062df167a01ac7a7ce6d9ae0be60e60fd0201",
34
34
  "referenceSha256": {
@@ -202,6 +202,14 @@ internal sealed class LegacyNative
202
202
  catch { /* Diagnostic metadata must never replace the original failure. */ }
203
203
  }
204
204
 
205
+ // A fixed reason the broker can report; the WMI exception text itself is never relayed.
206
+ internal Exception QueryTimeout(Exception error)
207
+ {
208
+ var timeout = new InvalidOperationException("Argument query timed out", error);
209
+ TagApi(timeout, LegacyFailureApi.WmiQuery);
210
+ return timeout;
211
+ }
212
+
205
213
  internal void TagFailure(Exception error, LegacyFailureStage substage, int? pid = null,
206
214
  long? creationTime = null, int? actualParentPid = null, int? expectedParentPid = null,
207
215
  string expectedParentCreationTime = null, bool? alive = null, bool? parentAlive = null,
@@ -376,7 +384,7 @@ internal sealed class LegacySnapshot
376
384
  if (pid == 0 ||
377
385
  pid > int.MaxValue) throw new InvalidOperationException("Snapshot identity unavailable");
378
386
  result.Add((int)pid);
379
- if (result.Count > 512) throw new InvalidOperationException("Child bound");
387
+ if (result.Count > 2048) throw new InvalidOperationException("Child bound");
380
388
  }
381
389
  }
382
390
  return result;
@@ -472,6 +480,8 @@ internal sealed class LegacyArgumentQueries : IDisposable
472
480
  private readonly Action check;
473
481
  private readonly List<LegacyQueryWorker> workers = new List<LegacyQueryWorker>();
474
482
  private volatile bool cancelled;
483
+ // The first real worker failure. Later workers only observe cancellation, which must not hide it.
484
+ private Exception primary;
475
485
  private int count;
476
486
  private int maximum;
477
487
  private bool disposed;
@@ -505,10 +515,13 @@ internal sealed class LegacyArgumentQueries : IDisposable
505
515
  workers[index].Queue.Add(batch);
506
516
  }
507
517
  foreach (var batch in wave)
508
- while (!batch.Done.Wait(25)) Check();
518
+ while (!batch.Done.Wait(25)) CheckPrimary();
519
+ var failure = Volatile.Read(ref primary);
509
520
  foreach (var batch in wave)
510
- if (batch.Error != null) throw batch.Error;
511
- Check();
521
+ if (failure == null &&
522
+ batch.Error != null) failure = batch.Error;
523
+ if (failure != null) throw failure;
524
+ CheckPrimary();
512
525
  if (capture)
513
526
  {
514
527
  foreach (var batch in wave)
@@ -548,7 +561,7 @@ internal sealed class LegacyArgumentQueries : IDisposable
548
561
  native.TagApi(error, LegacyFailureApi.WmiConnect);
549
562
  native.TagFailure(error, LegacyFailureStage.ArgumentConnect);
550
563
  worker.Error = error;
551
- cancelled = true;
564
+ Fail(error);
552
565
  }
553
566
  finally { worker.Ready.Set(); }
554
567
  foreach (var batch in worker.Queue.GetConsumingEnumerable())
@@ -561,9 +574,11 @@ internal sealed class LegacyArgumentQueries : IDisposable
561
574
  }
562
575
  catch (Exception error)
563
576
  {
564
- native.TagFailure(error, LegacyFailureStage.ArgumentQuery);
565
- batch.Error = error;
566
- cancelled = true;
577
+ var failure = TimedOut(error) ? native.QueryTimeout(error) : error;
578
+ native.TagFailure(failure, LegacyFailureStage.ArgumentQuery);
579
+ batch.Error = failure;
580
+ if (IsCancellation(failure)) cancelled = true;
581
+ else Fail(failure);
567
582
  }
568
583
  finally { batch.Done.Set(); }
569
584
  }
@@ -574,7 +589,7 @@ internal sealed class LegacyArgumentQueries : IDisposable
574
589
  }
575
590
  foreach (var worker in workers)
576
591
  {
577
- while (!worker.Ready.Wait(25)) Check();
592
+ while (!worker.Ready.Wait(25)) CheckPrimary();
578
593
  if (worker.Error != null) throw worker.Error;
579
594
  }
580
595
  }
@@ -592,7 +607,10 @@ internal sealed class LegacyArgumentQueries : IDisposable
592
607
  clauses.Add("ProcessId=" + process.Info.pid.ToString(CultureInfo.InvariantCulture));
593
608
  }
594
609
  var seen = new HashSet<int>();
595
- var options = new EnumerationOptions { Timeout = TimeSpan.FromSeconds(2), ReturnImmediately = false };
610
+ // Loaded hosts with ~1000 processes measured close to 1 s per query, so 2 s failed under contention.
611
+ // The caller's phase watchdog still bounds the whole capture, so a timeout late in a long
612
+ // capture can surface as that bound rather than as "Argument query timed out".
613
+ var options = new EnumerationOptions { Timeout = TimeSpan.FromSeconds(8), ReturnImmediately = false };
596
614
  using (var search = new ManagementObjectSearcher(scope, new ObjectQuery(
597
615
  "SELECT ProcessId,CreationDate,CommandLine FROM Win32_Process WHERE " + string.Join(" OR ", clauses)), options))
598
616
  using (var rows = QueryRows(search, native))
@@ -663,10 +681,41 @@ internal sealed class LegacyArgumentQueries : IDisposable
663
681
 
664
682
  private void Check()
665
683
  {
666
- if (cancelled) throw new InvalidOperationException("Argument query cancelled");
684
+ if (cancelled)
685
+ {
686
+ var cancellation = new InvalidOperationException("Argument query cancelled");
687
+ cancellation.Data["LegacyQueryCancelled"] = true;
688
+ throw cancellation;
689
+ }
667
690
  check();
668
691
  }
669
692
 
693
+ // Main thread only: report the first real worker failure rather than the cancellation it caused.
694
+ private void CheckPrimary()
695
+ {
696
+ var failure = Volatile.Read(ref primary);
697
+ if (failure != null) throw failure;
698
+ Check();
699
+ }
700
+
701
+ private void Fail(Exception error)
702
+ {
703
+ Interlocked.CompareExchange(ref primary, error, null);
704
+ cancelled = true;
705
+ }
706
+
707
+ private static bool IsCancellation(Exception error)
708
+ {
709
+ return error.Data.Contains("LegacyQueryCancelled");
710
+ }
711
+
712
+ private static bool TimedOut(Exception error)
713
+ {
714
+ var management = error as ManagementException;
715
+ return management != null &&
716
+ management.ErrorCode == ManagementStatus.Timedout;
717
+ }
718
+
670
719
  public void Dispose()
671
720
  {
672
721
  if (disposed) return;