mcp-pacemaker 1.3.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 +424 -0
- package/CONTRIBUTING.md +156 -0
- package/LICENSE +21 -0
- package/README.md +620 -0
- package/SECURITY.md +110 -0
- package/THIRD_PARTY_NOTICES.txt +366 -0
- package/autostart/windows/launcher.vbs +11 -0
- package/autostart/windows/register-task.ps1 +63 -0
- package/bin/cli.mjs +880 -0
- package/bin/config-checks.mjs +62 -0
- package/bin/logs.mjs +465 -0
- package/bin/mcp-bridge.mjs +2113 -0
- package/bin/pooling-config.mjs +553 -0
- package/bin/pooling-execution.mjs +91 -0
- package/bin/pooling-writer.mjs +169 -0
- package/bin/prewarm.mjs +152 -0
- package/bin/server-metrics.mjs +69 -0
- package/bin/service-control.mjs +124 -0
- package/bin/shared-sessions.mjs +1083 -0
- package/bin/top.mjs +115 -0
- package/bin/update-check.mjs +153 -0
- package/bin/windows/PoolingSecurityHelper.build.json +39 -0
- package/bin/windows/PoolingSecurityHelper.exe +0 -0
- package/bin/windows/src/AssemblyInfo.cs +11 -0
- package/bin/windows/src/PoolingSecurityHelper.cs +165 -0
- package/bin/windows/src/PoolingSecurityReader.cs +50 -0
- package/docs/shared-sessions.md +181 -0
- package/examples/servers.example.json +31 -0
- package/package.json +77 -0
- package/supervisor/bridge-child.mjs +14 -0
- package/supervisor/supervise.mjs +172 -0
- package/supervisor/supervise.ps1 +23 -0
- package/supervisor/supervise.sh +11 -0
- package/tools/windows-security-helper/README.md +95 -0
- package/tools/windows-security-helper/build.ps1 +262 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +366 -0
- package/ui/dist/assets/index-CIy7uxfd.css +1 -0
- package/ui/dist/assets/index-CtaaQsB9.js +8 -0
- package/ui/dist/index.html +14 -0
- package/ui/dist/third-party-manifest.json +579 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { PoolingConfigStore, PoolingConfigError } from './pooling-config.mjs';
|
|
3
|
+
import { PoolingExecution, POOLING_BUDGET_MS } from './pooling-execution.mjs';
|
|
4
|
+
|
|
5
|
+
if (!isMainThread &&
|
|
6
|
+
workerData?.kind === 'mcp-pooling-writer') {
|
|
7
|
+
const store = new PoolingConfigStore(workerData.configPath);
|
|
8
|
+
parentPort.on('message', ({ id, method, request, deadline, buffer }) => {
|
|
9
|
+
if (method === 'close') {
|
|
10
|
+
parentPort.close();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const execution = new PoolingExecution(deadline, buffer);
|
|
14
|
+
try {
|
|
15
|
+
execution.check();
|
|
16
|
+
if (method !== 'apply' &&
|
|
17
|
+
method !== 'undo') {
|
|
18
|
+
throw new PoolingConfigError(400, 'INVALID_REQUEST', 'Unknown pooling operation.');
|
|
19
|
+
}
|
|
20
|
+
const result = store[method](request, execution);
|
|
21
|
+
const completedLate = execution.complete();
|
|
22
|
+
parentPort.postMessage({ id, result, completedLate });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
execution.fail();
|
|
25
|
+
const known = error instanceof PoolingConfigError;
|
|
26
|
+
parentPort.postMessage({ id, error: {
|
|
27
|
+
statusCode: known ? error.statusCode : 500,
|
|
28
|
+
code: known ? error.code : 'WRITER_FAILED',
|
|
29
|
+
message: known ? error.message : 'Pooling writer failed. Reread the config before retrying.',
|
|
30
|
+
} });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class PoolingConfigWriter {
|
|
36
|
+
#configPath;
|
|
37
|
+
#worker;
|
|
38
|
+
#pending = new Map();
|
|
39
|
+
#sequence = 0;
|
|
40
|
+
#closed = false;
|
|
41
|
+
#closing;
|
|
42
|
+
#resolveClose;
|
|
43
|
+
#failure;
|
|
44
|
+
|
|
45
|
+
constructor(configPath) {
|
|
46
|
+
this.#configPath = configPath;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
apply(request, options = {}) {
|
|
50
|
+
return this.#send('apply', request, options);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
undo(request, options = {}) {
|
|
54
|
+
return this.#send('undo', request, options);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#send(method, request, options) {
|
|
58
|
+
if (this.#closed) {
|
|
59
|
+
return Promise.reject(new PoolingConfigError(503, 'WRITER_CLOSED', 'Pooling writer is shutting down.'));
|
|
60
|
+
}
|
|
61
|
+
if (this.#failure) return Promise.reject(this.#failure);
|
|
62
|
+
const defaultDeadline = process.hrtime.bigint() + BigInt(POOLING_BUDGET_MS) * 1000000n;
|
|
63
|
+
const deadline = options.deadline === undefined ? defaultDeadline
|
|
64
|
+
: options.deadline < defaultDeadline ? options.deadline : defaultDeadline;
|
|
65
|
+
const execution = new PoolingExecution(deadline);
|
|
66
|
+
if (options.signal?.aborted) return Promise.reject(execution.cancel());
|
|
67
|
+
try {
|
|
68
|
+
execution.check();
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return Promise.reject(error);
|
|
71
|
+
}
|
|
72
|
+
if (this.#pending.size >= 16) {
|
|
73
|
+
return Promise.reject(new PoolingConfigError(503, 'WRITER_BUSY', 'Too many pending pooling changes.'));
|
|
74
|
+
}
|
|
75
|
+
if (!this.#worker) this.#start();
|
|
76
|
+
const id = ++this.#sequence;
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const pending = { resolve, reject, execution, options, settled: false };
|
|
79
|
+
const cancel = (expired) => {
|
|
80
|
+
if (pending.settled) return;
|
|
81
|
+
const error = execution.cancel(expired);
|
|
82
|
+
if (!error) return;
|
|
83
|
+
this.#settle(pending, error);
|
|
84
|
+
};
|
|
85
|
+
pending.abort = () => cancel(false);
|
|
86
|
+
this.#pending.set(id, pending);
|
|
87
|
+
try {
|
|
88
|
+
options.signal?.addEventListener('abort', pending.abort, { once: true });
|
|
89
|
+
pending.timer = setTimeout(() => cancel(true), execution.remainingMs());
|
|
90
|
+
this.#worker.postMessage({ id, method, request, deadline, buffer: execution.state.buffer });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
this.#pending.delete(id);
|
|
93
|
+
this.#settle(pending, error);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#start() {
|
|
99
|
+
this.#worker = new Worker(new URL(import.meta.url), {
|
|
100
|
+
workerData: { kind: 'mcp-pooling-writer', configPath: this.#configPath },
|
|
101
|
+
});
|
|
102
|
+
this.#worker.on('message', ({ id, result, error, completedLate }) => {
|
|
103
|
+
const pending = this.#pending.get(id);
|
|
104
|
+
if (!pending) return;
|
|
105
|
+
this.#pending.delete(id);
|
|
106
|
+
const failure = error
|
|
107
|
+
? new PoolingConfigError(error.statusCode, error.code, error.message)
|
|
108
|
+
: completedLate ? pending.execution.committedLate() : undefined;
|
|
109
|
+
if (pending.settled ||
|
|
110
|
+
completedLate) this.#lateSettlement(pending, failure, result);
|
|
111
|
+
this.#settle(pending, failure, result);
|
|
112
|
+
});
|
|
113
|
+
this.#worker.on('error', () => this.#failed());
|
|
114
|
+
this.#worker.on('exit', (code) => {
|
|
115
|
+
if (code !== 0 ||
|
|
116
|
+
this.#pending.size > 0 ||
|
|
117
|
+
!this.#closed) {
|
|
118
|
+
this.#failed();
|
|
119
|
+
}
|
|
120
|
+
this.#worker = null;
|
|
121
|
+
this.#resolveClose?.();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#failed() {
|
|
126
|
+
this.#failure ??= new PoolingConfigError(500, 'WRITER_FAILED',
|
|
127
|
+
'Pooling writer stopped. Reread the config and restart the bridge before retrying.');
|
|
128
|
+
for (const pending of this.#pending.values()) {
|
|
129
|
+
const commitFailure = pending.execution.workerFailure();
|
|
130
|
+
const failure = commitFailure ?? this.#failure;
|
|
131
|
+
if (pending.settled ||
|
|
132
|
+
commitFailure) this.#lateSettlement(pending, failure, undefined, Boolean(commitFailure));
|
|
133
|
+
this.#settle(pending, failure);
|
|
134
|
+
}
|
|
135
|
+
this.#pending.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#settle(pending, error, result) {
|
|
139
|
+
if (pending.settled) return;
|
|
140
|
+
pending.settled = true;
|
|
141
|
+
clearTimeout(pending.timer);
|
|
142
|
+
pending.options.signal?.removeEventListener('abort', pending.abort);
|
|
143
|
+
if (error) pending.reject(error);
|
|
144
|
+
else pending.resolve(result);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#lateSettlement(pending, error, result, commitStarted = false) {
|
|
148
|
+
if (pending.options.onLateSettlement) {
|
|
149
|
+
try {
|
|
150
|
+
pending.options.onLateSettlement({ error, committed: Boolean(result), commitStarted });
|
|
151
|
+
} catch {
|
|
152
|
+
process.emitWarning('Unable to reconcile a pooling operation without a normal result.');
|
|
153
|
+
}
|
|
154
|
+
} else if (error &&
|
|
155
|
+
error.code !== 'WRITER_CANCELLED' &&
|
|
156
|
+
error.code !== 'WRITER_DEADLINE') {
|
|
157
|
+
process.emitWarning(`Pooling operation settled without a normal result (${error.code}). Reread the config.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
close() {
|
|
162
|
+
this.#closed = true;
|
|
163
|
+
if (this.#closing) return this.#closing;
|
|
164
|
+
if (!this.#worker) return Promise.resolve();
|
|
165
|
+
this.#closing = new Promise((resolve) => { this.#resolveClose = resolve; });
|
|
166
|
+
this.#worker.postMessage({ method: 'close' });
|
|
167
|
+
return this.#closing;
|
|
168
|
+
}
|
|
169
|
+
}
|
package/bin/prewarm.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
function portNumber(value) {
|
|
6
|
+
const port = Number(value);
|
|
7
|
+
if (!Number.isInteger(port) ||
|
|
8
|
+
port < 1 ||
|
|
9
|
+
port > 65535) {
|
|
10
|
+
throw new Error('Port must be an integer between 1 and 65535');
|
|
11
|
+
}
|
|
12
|
+
return port;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function jsonRequest(port, path, options = {}) {
|
|
16
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
17
|
+
...options,
|
|
18
|
+
signal: AbortSignal.timeout(10_000),
|
|
19
|
+
});
|
|
20
|
+
if (response.status === 401) {
|
|
21
|
+
throw new Error('Admin nonce was rejected. Check --config points to the running bridge, then retry.');
|
|
22
|
+
}
|
|
23
|
+
let result;
|
|
24
|
+
try {
|
|
25
|
+
result = await response.json();
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error(`Bridge on :${port} returned a non-JSON response (${response.status})`);
|
|
28
|
+
}
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new Error(result.error || `Bridge on :${port} rejected the request (${response.status})`);
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatPrewarm(snapshot) {
|
|
36
|
+
const seconds = (ms) => ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`;
|
|
37
|
+
const rows = [
|
|
38
|
+
['SERVER', 'MODE', 'WARM/TARGET', 'STARTS', 'COLD P50', 'P95', 'SUGGESTED', 'SHARED CHILD'],
|
|
39
|
+
...snapshot.servers.filter((server) => server.type === 'stdio').map((server) => [
|
|
40
|
+
server.name,
|
|
41
|
+
server.sharing,
|
|
42
|
+
server.sharing === 'shared' ? '-' : `${server.warm}/${server.minWarm}`,
|
|
43
|
+
String(server.spawn?.total ?? '-'),
|
|
44
|
+
seconds(server.spawn?.p50Ms),
|
|
45
|
+
seconds(server.spawn?.p95Ms),
|
|
46
|
+
server.prewarming?.eligible ? String(server.prewarming.suggestedMinWarm) : '-',
|
|
47
|
+
server.sharing === 'shared' ? server.shared
|
|
48
|
+
? `${server.shared.state}: ${server.shared.members} sessions, ${server.shared.unresolved} pending, ${server.shared.queued} queued`
|
|
49
|
+
: 'not active' : '-',
|
|
50
|
+
]),
|
|
51
|
+
];
|
|
52
|
+
const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
|
|
53
|
+
return [
|
|
54
|
+
`Pre-warming on :${snapshot.port} (process counters since ${snapshot.startedAt || 'bridge start'})`,
|
|
55
|
+
...rows.map((row) => row.map((cell, column) => cell.padEnd(widths[column])).join(' ').trimEnd()),
|
|
56
|
+
'Pre-warming is opt-in. Each warm slot holds one additional resident process.',
|
|
57
|
+
'Shared mode is separately opt-in: compatible stateless tools sessions reuse one initialized child.',
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function shellArgument(text) {
|
|
62
|
+
if (process.platform === 'win32') return `"${text}"`;
|
|
63
|
+
return `'${text.replaceAll("'", "'\\''")}'`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatUndoCommand(port, name, undoId, config) {
|
|
67
|
+
const executable = process.execPath;
|
|
68
|
+
const entry = fileURLToPath(new URL('./cli.mjs', import.meta.url));
|
|
69
|
+
const values = [executable, entry, name, undoId, ...(config ? [config] : [])];
|
|
70
|
+
const needsExplicitShell = process.platform === 'win32' &&
|
|
71
|
+
values.some((value) => /[%!$`"^\r\n]|\\$/.test(value));
|
|
72
|
+
if (needsExplicitShell) {
|
|
73
|
+
const quote = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
74
|
+
const configArg = config ? ` --config ${quote(config)}` : '';
|
|
75
|
+
const command = `& ${quote(executable)} ${quote(entry)} prewarm --port ${port}${configArg} --server ${quote(name)} --undo ${quote(undoId)}`;
|
|
76
|
+
return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(command, 'utf16le').toString('base64')}`;
|
|
77
|
+
}
|
|
78
|
+
const configArg = config ? ` --config ${shellArgument(config)}` : '';
|
|
79
|
+
const node = process.platform === 'win32' ? 'node.exe' : shellArgument(executable);
|
|
80
|
+
return `${node} ${shellArgument(entry)} prewarm --port ${port}${configArg} --server ${shellArgument(name)} --undo ${undoId}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function runPrewarm(options, { configPath, ports, write = console.log } = {}) {
|
|
84
|
+
const selectedPorts = options.port ? [portNumber(options.port)] : ports.map(portNumber);
|
|
85
|
+
const actionCount = [options.enable, options.disable, options.undo].filter(Boolean).length;
|
|
86
|
+
if (actionCount > 1) throw new Error('Choose only one of --enable, --disable or --undo');
|
|
87
|
+
if (actionCount &&
|
|
88
|
+
selectedPorts.length !== 1) {
|
|
89
|
+
throw new Error('Choose --port when changing a server on a multi-bridge setup');
|
|
90
|
+
}
|
|
91
|
+
if (options.count &&
|
|
92
|
+
!options.enable) {
|
|
93
|
+
throw new Error('--count requires --enable');
|
|
94
|
+
}
|
|
95
|
+
if (options.undo &&
|
|
96
|
+
!options.server) {
|
|
97
|
+
throw new Error('--undo requires --server');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const snapshots = [];
|
|
101
|
+
for (const port of selectedPorts) {
|
|
102
|
+
const snapshot = await jsonRequest(port, '/api/status');
|
|
103
|
+
if (snapshot.service !== 'mcp-pacemaker') throw new Error(`:${port} is not a pacemaker bridge`);
|
|
104
|
+
snapshots.push(snapshot);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!actionCount) {
|
|
108
|
+
if (options.json) {
|
|
109
|
+
write(JSON.stringify(snapshots.length === 1 ? snapshots[0] : snapshots));
|
|
110
|
+
} else {
|
|
111
|
+
for (const snapshot of snapshots) write(formatPrewarm(snapshot));
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const snapshot = snapshots[0];
|
|
117
|
+
if (!snapshot.prewarm) throw new Error('The running bridge does not support pooling changes; update and restart it first');
|
|
118
|
+
const name = options.enable || options.disable || options.server;
|
|
119
|
+
const server = snapshot.servers.find((candidate) => candidate.name === name);
|
|
120
|
+
if (!server) throw new Error(`Unknown server: ${name}`);
|
|
121
|
+
if (server.type !== 'stdio') throw new Error('HTTP proxies have no local child to pre-warm');
|
|
122
|
+
const body = { revision: snapshot.prewarm.revision };
|
|
123
|
+
if (options.undo) {
|
|
124
|
+
body.undoId = options.undo;
|
|
125
|
+
} else {
|
|
126
|
+
body.mode = options.enable ? 'pool' : 'isolated';
|
|
127
|
+
if (options.enable) {
|
|
128
|
+
if (!server.prewarming?.eligible) throw new Error(server.prewarming?.reason || 'Server is not eligible for pre-warming');
|
|
129
|
+
body.minWarm = options.count === undefined ? server.prewarming.suggestedMinWarm : Number(options.count);
|
|
130
|
+
if (!Number.isSafeInteger(body.minWarm) ||
|
|
131
|
+
body.minWarm < 1 ||
|
|
132
|
+
body.minWarm > snapshot.prewarm.maxWarm) {
|
|
133
|
+
throw new Error(`--count must be an integer between 1 and ${snapshot.prewarm.maxWarm}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const nonce = (await readFile(join(dirname(options.config || configPath), 'admin.nonce'), 'utf8')).trim();
|
|
138
|
+
const result = await jsonRequest(snapshot.port, `/admin/servers/${encodeURIComponent(name)}/pooling`, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: { 'content-type': 'application/json', 'x-mcp-nonce': nonce },
|
|
141
|
+
body: JSON.stringify(body),
|
|
142
|
+
});
|
|
143
|
+
if (result.ok !== true) throw new Error(result.error || 'Bridge did not confirm the pooling change');
|
|
144
|
+
if (options.json) {
|
|
145
|
+
write(JSON.stringify(result));
|
|
146
|
+
} else {
|
|
147
|
+
write(`${name}: ${options.undo ? 'restored previous pooling settings' : body.mode === 'pool' ? `enabled ${body.minWarm} warm slot(s)` : 'disabled pre-warming'}`);
|
|
148
|
+
if (result.undoId) {
|
|
149
|
+
write(`Undo: ${formatUndoCommand(snapshot.port, name, result.undoId, options.config)}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export class ServerMetrics {
|
|
2
|
+
constructor(sampleLimit = 50) {
|
|
3
|
+
this.sampleLimit = sampleLimit;
|
|
4
|
+
this.servers = new Map();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
get(name) {
|
|
8
|
+
let metrics = this.servers.get(name);
|
|
9
|
+
if (!metrics) {
|
|
10
|
+
metrics = {
|
|
11
|
+
attempts: 0,
|
|
12
|
+
total: 0,
|
|
13
|
+
failures: 0,
|
|
14
|
+
warmAdoptions: 0,
|
|
15
|
+
sessionStarts: 0,
|
|
16
|
+
sessionResumes: 0,
|
|
17
|
+
latencies: [],
|
|
18
|
+
};
|
|
19
|
+
this.servers.set(name, metrics);
|
|
20
|
+
}
|
|
21
|
+
return metrics;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
attemptingSpawn(name) {
|
|
25
|
+
this.get(name).attempts++;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
spawned(name) {
|
|
29
|
+
this.get(name).total++;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
failedSpawn(name) {
|
|
33
|
+
this.get(name).failures++;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
adoptedWarmChild(name) {
|
|
37
|
+
this.get(name).warmAdoptions++;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
openedSession(name, resumed = false) {
|
|
41
|
+
const metrics = this.get(name);
|
|
42
|
+
if (resumed) {
|
|
43
|
+
metrics.sessionResumes++;
|
|
44
|
+
} else {
|
|
45
|
+
metrics.sessionStarts++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
initialized(name, elapsedMs) {
|
|
50
|
+
const samples = this.get(name).latencies;
|
|
51
|
+
samples.push(elapsedMs);
|
|
52
|
+
if (samples.length > this.sampleLimit) samples.shift();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
snapshot(name) {
|
|
56
|
+
const { latencies, ...counts } = this.get(name);
|
|
57
|
+
const sorted = [...latencies].sort((a, b) => a - b);
|
|
58
|
+
const percentile = (fraction) => sorted.length
|
|
59
|
+
? sorted[Math.min(sorted.length - 1, Math.floor(fraction * sorted.length))]
|
|
60
|
+
: null;
|
|
61
|
+
return {
|
|
62
|
+
...counts,
|
|
63
|
+
samples: sorted.length,
|
|
64
|
+
p50Ms: percentile(0.5),
|
|
65
|
+
p95Ms: percentile(0.95),
|
|
66
|
+
maxMs: sorted.length ? sorted[sorted.length - 1] : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, realpathSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { connect } from 'node:net';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function servicePaths(config, port) {
|
|
7
|
+
if (!Number.isInteger(port) ||
|
|
8
|
+
port < 1 ||
|
|
9
|
+
port > 65535) {
|
|
10
|
+
throw new Error('Service port must be an integer from 1 to 65535.');
|
|
11
|
+
}
|
|
12
|
+
const directory = dirname(resolve(config));
|
|
13
|
+
const key = createHash('sha256').update(`${directory}\n${port}`).digest('hex').slice(0, 32);
|
|
14
|
+
const socket = process.platform === 'win32'
|
|
15
|
+
? `\\\\.\\pipe\\mcp-pacemaker-${key}`
|
|
16
|
+
: join(directory, `service-${port}.sock`);
|
|
17
|
+
if (process.platform !== 'win32' &&
|
|
18
|
+
Buffer.byteLength(socket) > 103) {
|
|
19
|
+
throw new Error('Service socket path exceeds the POSIX limit of 103 UTF-8 bytes. Use a shorter config directory; no alternate endpoint was selected.');
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
record: join(directory, `service-${port}.json`),
|
|
23
|
+
hold: join(directory, `service-${port}.stopped`),
|
|
24
|
+
socket,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertDurableRoot(root) {
|
|
29
|
+
if (/(^|[/\\])_npx([/\\]|$)/i.test(root)) {
|
|
30
|
+
throw new Error('Cache-backed npx roots cannot host an unattended service. Install in a durable root, then register autostart there. Use --no-autostart --no-start for wiring only.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function backendDescription(cliVersion, backend, action) {
|
|
35
|
+
const version = typeof backend?.version === 'string' ? backend.version : 'unknown';
|
|
36
|
+
const instance = typeof backend?.instanceId === 'string' ? backend.instanceId : 'unknown';
|
|
37
|
+
return `installed CLI ${cliVersion}; running backend ${version}; instance ${instance} — ${action}` +
|
|
38
|
+
(version !== cliVersion ? ' (version mismatch; adoption does not upgrade the backend)' : '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resumeService(config, port) {
|
|
42
|
+
try {
|
|
43
|
+
unlinkSync(servicePaths(config, port).hold);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code !== 'ENOENT') throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateServiceRecord(record, { root, config, port }) {
|
|
50
|
+
const matches = record?.protocol === 1 &&
|
|
51
|
+
record.root === realpathSync(root) &&
|
|
52
|
+
record.config === resolve(config) &&
|
|
53
|
+
record.port === port &&
|
|
54
|
+
record.socket === servicePaths(config, port).socket &&
|
|
55
|
+
typeof record.id === 'string' &&
|
|
56
|
+
/^[a-f0-9-]{36}$/.test(record.id) &&
|
|
57
|
+
typeof record.token === 'string' &&
|
|
58
|
+
/^[a-f0-9]{64}$/.test(record.token);
|
|
59
|
+
if (!matches) {
|
|
60
|
+
throw new Error('Managed service identity does not match this installation root, config, port and endpoint. Use the matching installation CLI; no process was stopped.');
|
|
61
|
+
}
|
|
62
|
+
return record;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function requestService(socket, request) {
|
|
66
|
+
return new Promise((resolveRequest, reject) => {
|
|
67
|
+
const client = connect(socket);
|
|
68
|
+
let data = '';
|
|
69
|
+
const finish = (error, response) => {
|
|
70
|
+
client.destroy();
|
|
71
|
+
if (error) reject(error);
|
|
72
|
+
else resolveRequest(response);
|
|
73
|
+
};
|
|
74
|
+
client.setTimeout(12000, () => finish(new Error('Supervisor response timed out; shutdown is not verified.')));
|
|
75
|
+
client.on('error', () => finish(new Error('Managed supervisor is unavailable; shutdown is not verified. Older or unmanaged services require verified OS-service shutdown.')));
|
|
76
|
+
client.on('connect', () => client.write(JSON.stringify(request) + '\n'));
|
|
77
|
+
client.on('data', (chunk) => {
|
|
78
|
+
data += chunk;
|
|
79
|
+
if (data.length > 16384) {
|
|
80
|
+
finish(new Error('Invalid supervisor response.'));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!data.includes('\n')) return;
|
|
84
|
+
let response;
|
|
85
|
+
try { response = JSON.parse(data.slice(0, data.indexOf('\n'))); }
|
|
86
|
+
catch { finish(new Error('Invalid supervisor response.')); return; }
|
|
87
|
+
finish(null, response);
|
|
88
|
+
});
|
|
89
|
+
client.on('end', () => finish(new Error('Supervisor closed before acknowledging shutdown.')));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function stopManagedService({ root, config, port, request = requestService }) {
|
|
94
|
+
const paths = servicePaths(config, port);
|
|
95
|
+
let record;
|
|
96
|
+
try {
|
|
97
|
+
record = JSON.parse(readFileSync(paths.record, 'utf8'));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error.code !== 'ENOENT') throw error;
|
|
100
|
+
throw new Error(`No managed supervisor record for :${port}. Older or direct bridges require verified OS-service shutdown and disabled autostart; no process was stopped.`);
|
|
101
|
+
}
|
|
102
|
+
validateServiceRecord(record, { root, config, port });
|
|
103
|
+
let receipt;
|
|
104
|
+
try {
|
|
105
|
+
receipt = JSON.parse(readFileSync(paths.hold, 'utf8'));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error.code !== 'ENOENT') throw error;
|
|
108
|
+
}
|
|
109
|
+
const completed = receipt?.stopped === true &&
|
|
110
|
+
receipt.id === record.id &&
|
|
111
|
+
receipt.root === record.root &&
|
|
112
|
+
receipt.config === record.config &&
|
|
113
|
+
receipt.port === record.port;
|
|
114
|
+
if (completed) {
|
|
115
|
+
return { root: record.root, config: record.config, port, supervisorId: record.id, alreadyStopped: true };
|
|
116
|
+
}
|
|
117
|
+
const response = await request(paths.socket, { action: 'stop', id: record.id, token: record.token });
|
|
118
|
+
if (response?.id !== record.id ||
|
|
119
|
+
response.stopped !== true ||
|
|
120
|
+
response.autostartHeld !== true) {
|
|
121
|
+
throw new Error('Supervisor did not verify shutdown and its autostart hold. Do not replace the installation root.');
|
|
122
|
+
}
|
|
123
|
+
return { root: record.root, config: record.config, port, supervisorId: record.id };
|
|
124
|
+
}
|