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.
- package/CHANGELOG.md +98 -2
- package/CONTRIBUTING.md +10 -3
- package/README.md +98 -760
- package/autostart/windows/legacy-access.ps1 +359 -0
- package/bin/account-upgrade-controller.mjs +1 -1
- package/bin/account-upgrade-worker.mjs +7 -0
- package/bin/cli-main.mjs +61 -5
- package/bin/dependency-lock.json +642 -0
- package/bin/legacy-access.mjs +73 -0
- package/bin/legacy-installation.mjs +75 -19
- package/bin/legacy-task.mjs +24 -0
- package/bin/legacy-upgrade.mjs +66 -10
- package/bin/managed-upgrade.mjs +102 -12
- package/bin/mcp-bridge.mjs +38 -20
- package/bin/prewarm.mjs +28 -6
- package/bin/service-control.mjs +10 -5
- package/bin/upgrade-capability.json +2 -1
- package/bin/upgrade-dependencies.mjs +67 -6
- package/bin/upgrade-package.mjs +2 -0
- package/bin/windows-legacy/LegacyProcessBroker.build.json +3 -3
- package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
- package/bin/windows-legacy/src/LegacyNative.cs +60 -11
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +317 -197
- package/bin/windows-legacy-process.mjs +12 -5
- package/docs/account-upgrades.md +17 -4
- package/docs/compatibility.md +39 -0
- package/docs/configuration.md +146 -0
- package/docs/dashboards.md +125 -0
- package/docs/execution-context.md +11 -5
- package/docs/http-api.md +47 -0
- package/docs/install.md +52 -0
- package/docs/operations.md +99 -0
- package/docs/upgrades.md +324 -0
- package/package.json +10 -3
- package/tools/windows-legacy/README.md +118 -92
- package/tools/windows-legacy/inventory.mjs +8 -6
- package/ui/dist/third-party-manifest.json +1 -1
package/bin/mcp-bridge.mjs
CHANGED
|
@@ -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 (
|
|
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 =
|
|
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
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
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 (
|
|
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.
|
|
2242
|
-
state.
|
|
2243
|
-
throw new Error('
|
|
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
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 };
|
package/bin/service-control.mjs
CHANGED
|
@@ -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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
52
|
-
!entry.integrity.startsWith('sha512-'))
|
|
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
|
-
|
|
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 });
|
package/bin/upgrade-package.mjs
CHANGED
|
@@ -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": "
|
|
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": "
|
|
31
|
-
"LegacyProcessBroker.cs": "
|
|
30
|
+
"LegacyNative.cs": "28f496cba02ebfbbe07102bc70133198b40846838c0189e0bfcdb91d2e0a95c6",
|
|
31
|
+
"LegacyProcessBroker.cs": "2edd5f77787498d5323185e00b663084e140c9aec6ebca3921d66078bbc66c2a"
|
|
32
32
|
},
|
|
33
33
|
"buildScriptSha256": "2ffa31155f6aa8f6b4239facb0f062df167a01ac7a7ce6d9ae0be60e60fd0201",
|
|
34
34
|
"referenceSha256": {
|
|
Binary file
|
|
@@ -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 >
|
|
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))
|
|
518
|
+
while (!batch.Done.Wait(25)) CheckPrimary();
|
|
519
|
+
var failure = Volatile.Read(ref primary);
|
|
509
520
|
foreach (var batch in wave)
|
|
510
|
-
if (
|
|
511
|
-
|
|
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
|
-
|
|
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.
|
|
565
|
-
|
|
566
|
-
|
|
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))
|
|
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
|
-
|
|
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)
|
|
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;
|