mcp-pacemaker 1.3.1 → 2.0.2
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 +85 -27
- package/CONTRIBUTING.md +123 -47
- package/README.md +117 -62
- package/SECURITY.md +34 -24
- package/bin/mcp-bridge.mjs +91 -42
- package/bin/pooling-batch-scheduler.mjs +129 -0
- package/bin/pooling-batches.mjs +270 -0
- package/bin/pooling-config.mjs +413 -389
- package/bin/pooling-editor.mjs +128 -0
- package/bin/pooling-errors.mjs +23 -0
- package/bin/pooling-execution.mjs +19 -6
- package/bin/pooling-files.mjs +541 -0
- package/bin/pooling-trace.mjs +218 -0
- package/bin/pooling-writer.mjs +87 -14
- package/bin/prewarm.mjs +87 -6
- package/bin/windows/PoolingSecurityHelper.build.json +7 -4
- package/bin/windows/PoolingSecurityHelper.exe +0 -0
- package/bin/windows/src/PoolingNativeFiles.cs +678 -0
- package/bin/windows/src/PoolingSecurityHelper.cs +18 -5
- package/package.json +2 -2
- package/tools/windows-security-helper/README.md +146 -3
- package/tools/windows-security-helper/build.ps1 +2 -2
- package/ui/dist/assets/index-CaLPg0c-.css +1 -0
- package/ui/dist/assets/index-CgPruWYi.js +9 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/third-party-manifest.json +37 -19
- package/ui/dist/assets/index-CIy7uxfd.css +0 -1
- package/ui/dist/assets/index-CtaaQsB9.js +0 -8
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { threadId } from 'node:worker_threads';
|
|
5
|
+
|
|
6
|
+
export const TRACE_MAX_EVENTS = 192;
|
|
7
|
+
export const TRACE_MAX_BYTES = 65536;
|
|
8
|
+
export const TRACE_MAX_LINE_BYTES = 1024;
|
|
9
|
+
const methods = ['apply', 'undo', 'stageApply', 'stageUndo', 'rejectStage', 'commitBatch'];
|
|
10
|
+
const codes = ['NONE', 'UNKNOWN', 'ETIMEDOUT', 'EACCES', 'EPERM', 'ENOENT', 'EIO',
|
|
11
|
+
'WRITER_DEADLINE', 'WRITER_CANCELLED', 'WRITER_FAILED', 'WRITER_OUTCOME_UNKNOWN',
|
|
12
|
+
'WRITER_DEADLINE_COMMITTED', 'WRITER_BUSY', 'WRITER_CLOSED', 'REVISION_CONFLICT',
|
|
13
|
+
'IO_ERROR', 'ACCESS_DENIED', 'FILE_BUSY', 'RECOVERY_REQUIRED', 'STORE_BUSY'];
|
|
14
|
+
const integer = (value) => Number.isSafeInteger(value) && value >= 0 && value <= 0xffffffff;
|
|
15
|
+
const state = (value) => Number.isInteger(value) && value >= 0 && value <= 6;
|
|
16
|
+
const duration = (value) => Number.isFinite(value) && Math.abs(value) <= 120000;
|
|
17
|
+
const boolean = (value) => typeof value === 'boolean';
|
|
18
|
+
const method = (value) => methods.includes(value);
|
|
19
|
+
const code = (value) => codes.includes(value);
|
|
20
|
+
const action = (value) => ['inspect-access', 'stage', 'move-no-replace'].includes(value);
|
|
21
|
+
const fields = {
|
|
22
|
+
'request-start': {},
|
|
23
|
+
'body-end': { bytes: integer },
|
|
24
|
+
'response': { status: (value) => Number.isInteger(value) && value >= 100 && value <= 599 },
|
|
25
|
+
'queue-enter': { queued: integer, applying: boolean },
|
|
26
|
+
'queue-leave': {},
|
|
27
|
+
'writer-send': { method, queued: integer, cold: boolean },
|
|
28
|
+
'worker-ready': {},
|
|
29
|
+
'worker-enter': { method, state },
|
|
30
|
+
'worker-result': { state },
|
|
31
|
+
'worker-error': { code, state },
|
|
32
|
+
'writer-expire': { expired: boolean, state },
|
|
33
|
+
'writer-result': { code, state },
|
|
34
|
+
'store-start': {},
|
|
35
|
+
'store-ready': {},
|
|
36
|
+
'helper-start': { action, call: integer, timeoutMs: duration },
|
|
37
|
+
'helper-end': { action, call: integer, helperPid: integer,
|
|
38
|
+
exit: (value) => value === null || Number.isInteger(value) && Math.abs(value) <= 0xffffffff, code },
|
|
39
|
+
'publish-start': {},
|
|
40
|
+
'publish-end': {},
|
|
41
|
+
'capture-truncated': {},
|
|
42
|
+
'capture-failed': {},
|
|
43
|
+
'capture-invalid': {},
|
|
44
|
+
};
|
|
45
|
+
const base = ['version', 'event', 'operation', 'pid', 'thread', 'sequence', 'elapsedMs', 'remainingMs'];
|
|
46
|
+
const uuid = (value) => typeof value === 'string' &&
|
|
47
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
|
|
48
|
+
|
|
49
|
+
export function validateTraceRecord(record) {
|
|
50
|
+
if (!record ||
|
|
51
|
+
Object.getPrototypeOf(record) !== Object.prototype ||
|
|
52
|
+
!Object.hasOwn(fields, record.event)) throw new Error('Invalid pooling trace record.');
|
|
53
|
+
const schema = fields[record.event];
|
|
54
|
+
const keys = [...base, ...Object.keys(schema)];
|
|
55
|
+
const valid = record.version === 1 && uuid(record.operation) &&
|
|
56
|
+
integer(record.pid) && integer(record.thread) && integer(record.sequence) &&
|
|
57
|
+
record.sequence > 0 && record.sequence <= TRACE_MAX_EVENTS &&
|
|
58
|
+
duration(record.elapsedMs) && duration(record.remainingMs) &&
|
|
59
|
+
Object.keys(record).length === keys.length &&
|
|
60
|
+
keys.every((key) => Object.hasOwn(record, key)) &&
|
|
61
|
+
Object.entries(schema).every(([key, validate]) => validate(record[key]));
|
|
62
|
+
if (!valid ||
|
|
63
|
+
Buffer.byteLength(JSON.stringify(record) + '\n') > TRACE_MAX_LINE_BYTES) {
|
|
64
|
+
throw new Error('Invalid pooling trace record.');
|
|
65
|
+
}
|
|
66
|
+
return record;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function traceErrorCode(error) {
|
|
70
|
+
try {
|
|
71
|
+
return error == null ? 'NONE' : codes.includes(error.code) ? error.code : 'UNKNOWN';
|
|
72
|
+
} catch {
|
|
73
|
+
return 'UNKNOWN';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
class PoolingTraceOperation {
|
|
78
|
+
constructor(sink, deadline, context) {
|
|
79
|
+
this.sink = sink;
|
|
80
|
+
this.deadline = deadline;
|
|
81
|
+
this.context = context;
|
|
82
|
+
this.calls = 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
helper() {
|
|
86
|
+
return ++this.calls;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
record(event, extra = {}) {
|
|
90
|
+
this.sink.record(this, event, extra);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class PoolingTrace {
|
|
95
|
+
#descriptor;
|
|
96
|
+
#path;
|
|
97
|
+
#identity;
|
|
98
|
+
#written = 0;
|
|
99
|
+
#owned = false;
|
|
100
|
+
#closed = false;
|
|
101
|
+
|
|
102
|
+
constructor(directory) {
|
|
103
|
+
this.directory = directory;
|
|
104
|
+
this.events = 0;
|
|
105
|
+
this.bytes = 0;
|
|
106
|
+
this.stopped = false;
|
|
107
|
+
this.failed = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
operation(deadline, context) {
|
|
111
|
+
if (!this.directory ||
|
|
112
|
+
this.#closed) return undefined;
|
|
113
|
+
try {
|
|
114
|
+
context ??= { operation: randomUUID(), started: String(process.hrtime.bigint()), deadline: String(deadline) };
|
|
115
|
+
const valid = uuid(context.operation) &&
|
|
116
|
+
typeof context.started === 'string' && /^\d{1,24}$/.test(context.started) &&
|
|
117
|
+
typeof context.deadline === 'string' && /^\d{1,24}$/.test(context.deadline);
|
|
118
|
+
if (!valid) return undefined;
|
|
119
|
+
return new PoolingTraceOperation(this, deadline ?? BigInt(context.deadline), {
|
|
120
|
+
operation: context.operation, started: context.started, deadline: context.deadline,
|
|
121
|
+
});
|
|
122
|
+
} catch {
|
|
123
|
+
this.failed = true;
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
record(operation, event, extra) {
|
|
129
|
+
if (this.stopped ||
|
|
130
|
+
this.#closed) return;
|
|
131
|
+
try {
|
|
132
|
+
const now = process.hrtime.bigint();
|
|
133
|
+
const milliseconds = (value) => Math.round(Math.max(-120000, Math.min(120000, Number(value) / 1e6)) * 1000) / 1000;
|
|
134
|
+
const record = {
|
|
135
|
+
version: 1, event, operation: operation.context.operation,
|
|
136
|
+
pid: process.pid, thread: threadId, sequence: this.events + 1,
|
|
137
|
+
elapsedMs: milliseconds(now - BigInt(operation.context.started)),
|
|
138
|
+
remainingMs: milliseconds(operation.deadline - now),
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
if (!Object.hasOwn(fields, event) ||
|
|
142
|
+
Object.keys(extra).some((key) => !Object.hasOwn(fields[event], key))) {
|
|
143
|
+
throw new Error('Invalid pooling trace fields.');
|
|
144
|
+
}
|
|
145
|
+
Object.assign(record, extra);
|
|
146
|
+
validateTraceRecord(record);
|
|
147
|
+
} catch {
|
|
148
|
+
record.event = 'capture-invalid';
|
|
149
|
+
for (const key of Object.keys(record)) {
|
|
150
|
+
if (!base.includes(key)) delete record[key];
|
|
151
|
+
}
|
|
152
|
+
this.stopped = true;
|
|
153
|
+
}
|
|
154
|
+
const full = this.events >= TRACE_MAX_EVENTS - 1 ||
|
|
155
|
+
this.bytes + Buffer.byteLength(JSON.stringify(record) + '\n') > TRACE_MAX_BYTES - TRACE_MAX_LINE_BYTES;
|
|
156
|
+
if (full ||
|
|
157
|
+
this.failed) {
|
|
158
|
+
record.event = this.failed ? 'capture-failed' : 'capture-truncated';
|
|
159
|
+
for (const key of Object.keys(record)) {
|
|
160
|
+
if (!base.includes(key)) delete record[key];
|
|
161
|
+
}
|
|
162
|
+
this.stopped = true;
|
|
163
|
+
}
|
|
164
|
+
validateTraceRecord(record);
|
|
165
|
+
const line = JSON.stringify(record) + '\n';
|
|
166
|
+
this.#append(line);
|
|
167
|
+
this.events++;
|
|
168
|
+
this.bytes += Buffer.byteLength(line);
|
|
169
|
+
if (this.stopped) this.close();
|
|
170
|
+
} catch {
|
|
171
|
+
// Diagnostics must never replace the operation's result or error.
|
|
172
|
+
this.failed = true;
|
|
173
|
+
if (this.#owned) this.close();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#append(line) {
|
|
178
|
+
if (this.#descriptor === undefined) {
|
|
179
|
+
this.#path = join(this.directory, `${process.pid}-${threadId}.jsonl`);
|
|
180
|
+
this.#descriptor = fs.openSync(this.#path, 'wx', 0o600);
|
|
181
|
+
this.#owned = true;
|
|
182
|
+
this.#identity = fs.fstatSync(this.#descriptor, { bigint: true });
|
|
183
|
+
}
|
|
184
|
+
this.#checkOwned(this.#written);
|
|
185
|
+
// A replacement after the check cannot redirect this write to another file.
|
|
186
|
+
fs.appendFileSync(this.#descriptor, line);
|
|
187
|
+
const written = this.#written + Buffer.byteLength(line);
|
|
188
|
+
this.#checkOwned(written);
|
|
189
|
+
this.#written = written;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#checkOwned(bytes) {
|
|
193
|
+
const open = fs.fstatSync(this.#descriptor, { bigint: true });
|
|
194
|
+
const named = fs.lstatSync(this.#path, { bigint: true });
|
|
195
|
+
const owned = [open, named].every((stat) =>
|
|
196
|
+
stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1n &&
|
|
197
|
+
stat.dev === this.#identity.dev && stat.ino === this.#identity.ino &&
|
|
198
|
+
stat.size === BigInt(bytes));
|
|
199
|
+
if (!owned) throw new Error('Pooling trace file ownership changed.');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
close() {
|
|
203
|
+
if (this.#closed) return;
|
|
204
|
+
this.#closed = true;
|
|
205
|
+
this.stopped = true;
|
|
206
|
+
const descriptor = this.#descriptor;
|
|
207
|
+
this.#descriptor = undefined;
|
|
208
|
+
if (descriptor === undefined) return;
|
|
209
|
+
try {
|
|
210
|
+
fs.closeSync(descriptor);
|
|
211
|
+
} catch {
|
|
212
|
+
this.failed = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const poolingTrace = new PoolingTrace(process.env.MCP_POOLING_TRACE_DIR);
|
|
218
|
+
if (poolingTrace.directory) process.once('exit', () => poolingTrace.close());
|
package/bin/pooling-writer.mjs
CHANGED
|
@@ -1,32 +1,51 @@
|
|
|
1
1
|
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
|
|
2
2
|
import { PoolingConfigStore, PoolingConfigError } from './pooling-config.mjs';
|
|
3
3
|
import { PoolingExecution, POOLING_BUDGET_MS } from './pooling-execution.mjs';
|
|
4
|
+
import { poolingTrace, traceErrorCode } from './pooling-trace.mjs';
|
|
4
5
|
|
|
5
6
|
if (!isMainThread &&
|
|
6
7
|
workerData?.kind === 'mcp-pooling-writer') {
|
|
8
|
+
poolingTrace.operation(undefined, workerData.traceContext)?.record('worker-ready');
|
|
7
9
|
const store = new PoolingConfigStore(workerData.configPath);
|
|
8
|
-
parentPort.on('message', ({ id, method, request, deadline, buffer }) => {
|
|
10
|
+
parentPort.on('message', ({ id, method, request, deadline, buffer, traceContext }) => {
|
|
9
11
|
if (method === 'close') {
|
|
12
|
+
store.close();
|
|
10
13
|
parentPort.close();
|
|
11
14
|
return;
|
|
12
15
|
}
|
|
13
16
|
const execution = new PoolingExecution(deadline, buffer);
|
|
17
|
+
execution.trace = poolingTrace.operation(deadline, traceContext);
|
|
18
|
+
execution.trace?.record('worker-enter', { method, state: Atomics.load(execution.state, 0) });
|
|
19
|
+
let cancelledBatchId;
|
|
14
20
|
try {
|
|
15
21
|
execution.check();
|
|
16
|
-
if (
|
|
17
|
-
method !== 'undo') {
|
|
22
|
+
if (!['apply', 'undo', 'stageApply', 'stageUndo', 'rejectStage', 'commitBatch'].includes(method)) {
|
|
18
23
|
throw new PoolingConfigError(400, 'INVALID_REQUEST', 'Unknown pooling operation.');
|
|
19
24
|
}
|
|
20
25
|
const result = store[method](request, execution);
|
|
21
|
-
|
|
26
|
+
if (result.cancelled) cancelledBatchId = result.batchId;
|
|
27
|
+
let completedLate;
|
|
28
|
+
try {
|
|
29
|
+
completedLate = execution.complete();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (result.pending &&
|
|
32
|
+
result.accepted !== false) {
|
|
33
|
+
store.rejectStage(result, new PoolingExecution(
|
|
34
|
+
process.hrtime.bigint() + BigInt(POOLING_BUDGET_MS) * 1000000n));
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
execution.trace?.record('worker-result', { state: Atomics.load(execution.state, 0) });
|
|
22
39
|
parentPort.postMessage({ id, result, completedLate });
|
|
23
40
|
} catch (error) {
|
|
24
|
-
execution.
|
|
41
|
+
execution.trace?.record('worker-error', { code: traceErrorCode(error), state: Atomics.load(execution.state, 0) });
|
|
42
|
+
execution.fail(error.commitState);
|
|
25
43
|
const known = error instanceof PoolingConfigError;
|
|
26
|
-
parentPort.postMessage({ id, error: {
|
|
44
|
+
parentPort.postMessage({ id, cancelledBatchId, error: {
|
|
27
45
|
statusCode: known ? error.statusCode : 500,
|
|
28
46
|
code: known ? error.code : 'WRITER_FAILED',
|
|
29
47
|
message: known ? error.message : 'Pooling writer failed. Reread the config before retrying.',
|
|
48
|
+
commitState: error.commitState,
|
|
30
49
|
} });
|
|
31
50
|
}
|
|
32
51
|
});
|
|
@@ -54,6 +73,22 @@ export class PoolingConfigWriter {
|
|
|
54
73
|
return this.#send('undo', request, options);
|
|
55
74
|
}
|
|
56
75
|
|
|
76
|
+
stageApply(request, options = {}) {
|
|
77
|
+
return this.#send('stageApply', request, options);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
stageUndo(request, options = {}) {
|
|
81
|
+
return this.#send('stageUndo', request, options);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
commitBatch(request, options = {}) {
|
|
85
|
+
return this.#send('commitBatch', request, options);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
rejectStage(request) {
|
|
89
|
+
return this.#send('rejectStage', request, {});
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
#send(method, request, options) {
|
|
58
93
|
if (this.#closed) {
|
|
59
94
|
return Promise.reject(new PoolingConfigError(503, 'WRITER_CLOSED', 'Pooling writer is shutting down.'));
|
|
@@ -63,6 +98,7 @@ export class PoolingConfigWriter {
|
|
|
63
98
|
const deadline = options.deadline === undefined ? defaultDeadline
|
|
64
99
|
: options.deadline < defaultDeadline ? options.deadline : defaultDeadline;
|
|
65
100
|
const execution = new PoolingExecution(deadline);
|
|
101
|
+
execution.trace = options.trace ?? poolingTrace.operation(deadline);
|
|
66
102
|
if (options.signal?.aborted) return Promise.reject(execution.cancel());
|
|
67
103
|
try {
|
|
68
104
|
execution.check();
|
|
@@ -72,12 +108,14 @@ export class PoolingConfigWriter {
|
|
|
72
108
|
if (this.#pending.size >= 16) {
|
|
73
109
|
return Promise.reject(new PoolingConfigError(503, 'WRITER_BUSY', 'Too many pending pooling changes.'));
|
|
74
110
|
}
|
|
75
|
-
|
|
111
|
+
execution.trace?.record('writer-send', { method, queued: this.#pending.size, cold: !this.#worker });
|
|
112
|
+
if (!this.#worker) this.#start(execution.trace);
|
|
76
113
|
const id = ++this.#sequence;
|
|
77
114
|
return new Promise((resolve, reject) => {
|
|
78
|
-
const pending = { resolve, reject, execution, options, settled: false };
|
|
115
|
+
const pending = { resolve, reject, execution, options, method, settled: false };
|
|
79
116
|
const cancel = (expired) => {
|
|
80
117
|
if (pending.settled) return;
|
|
118
|
+
execution.trace?.record('writer-expire', { expired, state: Atomics.load(execution.state, 0) });
|
|
81
119
|
const error = execution.cancel(expired);
|
|
82
120
|
if (!error) return;
|
|
83
121
|
this.#settle(pending, error);
|
|
@@ -87,7 +125,8 @@ export class PoolingConfigWriter {
|
|
|
87
125
|
try {
|
|
88
126
|
options.signal?.addEventListener('abort', pending.abort, { once: true });
|
|
89
127
|
pending.timer = setTimeout(() => cancel(true), execution.remainingMs());
|
|
90
|
-
this.#worker.postMessage({ id, method, request, deadline, buffer: execution.state.buffer
|
|
128
|
+
this.#worker.postMessage({ id, method, request, deadline, buffer: execution.state.buffer,
|
|
129
|
+
traceContext: execution.trace?.context });
|
|
91
130
|
} catch (error) {
|
|
92
131
|
this.#pending.delete(id);
|
|
93
132
|
this.#settle(pending, error);
|
|
@@ -95,17 +134,45 @@ export class PoolingConfigWriter {
|
|
|
95
134
|
});
|
|
96
135
|
}
|
|
97
136
|
|
|
98
|
-
#start() {
|
|
137
|
+
#start(trace) {
|
|
99
138
|
this.#worker = new Worker(new URL(import.meta.url), {
|
|
100
|
-
workerData: { kind: 'mcp-pooling-writer', configPath: this.#configPath },
|
|
139
|
+
workerData: { kind: 'mcp-pooling-writer', configPath: this.#configPath, traceContext: trace?.context },
|
|
101
140
|
});
|
|
102
|
-
this.#worker.on('message', ({ id, result, error, completedLate }) => {
|
|
141
|
+
this.#worker.on('message', async ({ id, result, error, completedLate, cancelledBatchId }) => {
|
|
103
142
|
const pending = this.#pending.get(id);
|
|
104
143
|
if (!pending) return;
|
|
144
|
+
pending.execution.trace?.record('writer-result', {
|
|
145
|
+
code: traceErrorCode(error), state: Atomics.load(pending.execution.state, 0),
|
|
146
|
+
});
|
|
105
147
|
this.#pending.delete(id);
|
|
106
|
-
|
|
148
|
+
let failure = error
|
|
107
149
|
? new PoolingConfigError(error.statusCode, error.code, error.message)
|
|
108
150
|
: completedLate ? pending.execution.committedLate() : undefined;
|
|
151
|
+
cancelledBatchId ??= result?.cancelled ? result.batchId : undefined;
|
|
152
|
+
if (!failure &&
|
|
153
|
+
cancelledBatchId &&
|
|
154
|
+
pending.options.signal?.aborted) {
|
|
155
|
+
failure = new PoolingConfigError(408, 'WRITER_CANCELLED',
|
|
156
|
+
'Pooling change was cancelled before config commit.');
|
|
157
|
+
}
|
|
158
|
+
if (failure) {
|
|
159
|
+
failure.commitState = error?.commitState ?? failure.commitState ??
|
|
160
|
+
(pending.method === 'commitBatch' ? 'not-committed' : undefined);
|
|
161
|
+
if (cancelledBatchId) failure.cancelledBatchId = cancelledBatchId;
|
|
162
|
+
}
|
|
163
|
+
if (!failure &&
|
|
164
|
+
result?.pending &&
|
|
165
|
+
result.accepted !== false &&
|
|
166
|
+
pending.options.signal?.aborted) {
|
|
167
|
+
try {
|
|
168
|
+
await this.rejectStage({ generation: result.generation });
|
|
169
|
+
this.#settle(pending, new PoolingConfigError(408, 'WRITER_CANCELLED',
|
|
170
|
+
'Pooling change was cancelled before config commit.'));
|
|
171
|
+
} catch (cleanupError) {
|
|
172
|
+
this.#settle(pending, cleanupError);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
109
176
|
if (pending.settled ||
|
|
110
177
|
completedLate) this.#lateSettlement(pending, failure, result);
|
|
111
178
|
this.#settle(pending, failure, result);
|
|
@@ -147,7 +214,13 @@ export class PoolingConfigWriter {
|
|
|
147
214
|
#lateSettlement(pending, error, result, commitStarted = false) {
|
|
148
215
|
if (pending.options.onLateSettlement) {
|
|
149
216
|
try {
|
|
150
|
-
pending.options.onLateSettlement({
|
|
217
|
+
pending.options.onLateSettlement({
|
|
218
|
+
error, result,
|
|
219
|
+
committed: Boolean(result && !result.pending && !result.cancelled) ||
|
|
220
|
+
error?.commitState === 'committed',
|
|
221
|
+
cancelledBatchId: error?.cancelledBatchId ?? (result?.cancelled ? result.batchId : undefined),
|
|
222
|
+
commitStarted,
|
|
223
|
+
});
|
|
151
224
|
} catch {
|
|
152
225
|
process.emitWarning('Unable to reconcile a pooling operation without a normal result.');
|
|
153
226
|
}
|
package/bin/prewarm.mjs
CHANGED
|
@@ -29,10 +29,46 @@ async function jsonRequest(port, path, options = {}) {
|
|
|
29
29
|
if (!response.ok) {
|
|
30
30
|
throw new Error(result.error || `Bridge on :${port} rejected the request (${response.status})`);
|
|
31
31
|
}
|
|
32
|
-
return result;
|
|
32
|
+
return { status: response.status, body: result };
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
function validatePendingReceipt(receipt, name, port) {
|
|
36
|
+
const invalid = 'Bridge returned an invalid HTTP 202 receipt. Reread settings before retrying.';
|
|
37
|
+
const nonempty = (value) => typeof value === 'string' && value.length > 0;
|
|
38
|
+
const fieldsValid = receipt?.ok === true && receipt.pending === true &&
|
|
39
|
+
receipt.name === name && nonempty(receipt.batchId) &&
|
|
40
|
+
nonempty(receipt.undoId) && nonempty(receipt.revision);
|
|
41
|
+
if (!fieldsValid) throw new Error(invalid);
|
|
42
|
+
|
|
43
|
+
const snapshot = receipt.snapshot;
|
|
44
|
+
const snapshotValid = snapshot?.service === 'mcp-pacemaker' &&
|
|
45
|
+
snapshot.port === port && nonempty(snapshot.instanceId) &&
|
|
46
|
+
Number.isSafeInteger(snapshot.snapshotVersion) && snapshot.snapshotVersion > 0 &&
|
|
47
|
+
Array.isArray(snapshot.servers) && snapshot.servers.some((server) => server?.name === name) &&
|
|
48
|
+
snapshot.prewarm?.revision === receipt.revision &&
|
|
49
|
+
Number.isSafeInteger(snapshot.prewarm.maxWarm) && snapshot.prewarm.maxWarm > 0 &&
|
|
50
|
+
Number.isFinite(snapshot.prewarm.batchDelayMs) && snapshot.prewarm.batchDelayMs > 0 &&
|
|
51
|
+
Array.isArray(snapshot.prewarm.batches);
|
|
52
|
+
if (!snapshotValid) throw new Error(invalid);
|
|
53
|
+
|
|
54
|
+
const batch = snapshot.prewarm.batches.find((candidate) => candidate?.id === receipt.batchId);
|
|
55
|
+
const timingValid = batch?.status === 'pending' ? Number.isFinite(batch.applyAt)
|
|
56
|
+
: batch?.status === 'applying' && batch.applyAt === null;
|
|
57
|
+
const changesValid = Array.isArray(batch?.changes) && batch.changes.length > 0 &&
|
|
58
|
+
batch.changes.some((change) => change?.name === name) &&
|
|
59
|
+
batch.changes.every((change) => nonempty(change?.name) &&
|
|
60
|
+
(change.mode === 'pool'
|
|
61
|
+
? change.minWarm === undefined ||
|
|
62
|
+
Number.isSafeInteger(change.minWarm) && change.minWarm >= 1 && change.minWarm <= snapshot.prewarm.maxWarm
|
|
63
|
+
: change.mode === 'isolated' && change.minWarm === undefined));
|
|
64
|
+
if (!timingValid ||
|
|
65
|
+
!changesValid ||
|
|
66
|
+
batch.revision !== receipt.revision) {
|
|
67
|
+
throw new Error(invalid);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatPrewarm(snapshot, now = Date.now()) {
|
|
36
72
|
const seconds = (ms) => ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`;
|
|
37
73
|
const rows = [
|
|
38
74
|
['SERVER', 'MODE', 'WARM/TARGET', 'STARTS', 'COLD P50', 'P95', 'SUGGESTED', 'SHARED CHILD'],
|
|
@@ -50,11 +86,30 @@ export function formatPrewarm(snapshot) {
|
|
|
50
86
|
]),
|
|
51
87
|
];
|
|
52
88
|
const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
|
|
89
|
+
const history = snapshot.prewarm?.batches ?? [];
|
|
90
|
+
const latestFinished = history.find((batch) => ['applied', 'failed', 'cancelled'].includes(batch.status));
|
|
91
|
+
const batches = history
|
|
92
|
+
.filter((batch) => ['pending', 'applying'].includes(batch.status) ||
|
|
93
|
+
batch === latestFinished && batch.status === 'failed')
|
|
94
|
+
.map((batch) => {
|
|
95
|
+
const changes = batch.changes.map((change) => {
|
|
96
|
+
const target = change.minWarm === undefined
|
|
97
|
+
? 'pooling with configured target' : `${change.minWarm} warm slot(s)`;
|
|
98
|
+
return `${change.name}: ${change.mode === 'pool' ? target : 'pre-warming off'}`;
|
|
99
|
+
}).join(', ');
|
|
100
|
+
if (batch.status === 'failed') return `Batch failed (${changes}): ${batch.error || 'Reread settings before retrying.'}`;
|
|
101
|
+
if (batch.status === 'applying') return `Applying batch: ${changes}.`;
|
|
102
|
+
const secondsLeft = Math.max(0, Math.ceil((batch.applyAt - now) / 1000));
|
|
103
|
+
return `Pending batch (reload in ${secondsLeft}s): ${changes}. Active settings above have not changed.`;
|
|
104
|
+
});
|
|
53
105
|
return [
|
|
54
106
|
`Pre-warming on :${snapshot.port} (process counters since ${snapshot.startedAt || 'bridge start'})`,
|
|
55
107
|
...rows.map((row) => row.map((cell, column) => cell.padEnd(widths[column])).join(' ').trimEnd()),
|
|
56
108
|
'Pre-warming is opt-in. Each warm slot holds one additional resident process.',
|
|
57
109
|
'Shared mode is separately opt-in: compatible stateless tools sessions reuse one initialized child.',
|
|
110
|
+
...(typeof snapshot.prewarm?.saveWarning === 'string'
|
|
111
|
+
? [`File save notice: ${snapshot.prewarm.saveWarning}`] : []),
|
|
112
|
+
...batches,
|
|
58
113
|
].join('\n');
|
|
59
114
|
}
|
|
60
115
|
|
|
@@ -99,7 +154,7 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
|
|
|
99
154
|
|
|
100
155
|
const snapshots = [];
|
|
101
156
|
for (const port of selectedPorts) {
|
|
102
|
-
const snapshot = await jsonRequest(port, '/api/status');
|
|
157
|
+
const { body: snapshot } = await jsonRequest(port, '/api/status');
|
|
103
158
|
if (snapshot.service !== 'mcp-pacemaker') throw new Error(`:${port} is not a pacemaker bridge`);
|
|
104
159
|
snapshots.push(snapshot);
|
|
105
160
|
}
|
|
@@ -135,14 +190,40 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
|
|
|
135
190
|
}
|
|
136
191
|
}
|
|
137
192
|
const nonce = (await readFile(join(dirname(options.config || configPath), 'admin.nonce'), 'utf8')).trim();
|
|
138
|
-
|
|
193
|
+
if (!options.json &&
|
|
194
|
+
typeof snapshot.prewarm.saveWarning === 'string') {
|
|
195
|
+
write(`File save notice: ${snapshot.prewarm.saveWarning}`);
|
|
196
|
+
}
|
|
197
|
+
const response = await jsonRequest(snapshot.port, `/admin/servers/${encodeURIComponent(name)}/pooling`, {
|
|
139
198
|
method: 'POST',
|
|
140
|
-
headers: { 'content-type': 'application/json', 'x-mcp-nonce': nonce },
|
|
199
|
+
headers: { 'content-type': 'application/json', 'x-mcp-nonce': nonce, 'x-mcp-pooling-batch': '1' },
|
|
141
200
|
body: JSON.stringify(body),
|
|
142
201
|
});
|
|
143
|
-
|
|
202
|
+
const result = response.body;
|
|
203
|
+
if (response.status === 202) {
|
|
204
|
+
validatePendingReceipt(result, name, snapshot.port);
|
|
205
|
+
} else if (response.status !== 200 ||
|
|
206
|
+
result?.pending === true) {
|
|
207
|
+
throw new Error('Bridge returned an unexpected pooling response status. Reread settings before retrying.');
|
|
208
|
+
}
|
|
209
|
+
if (result?.ok !== true) throw new Error(result?.error || 'Bridge did not confirm the pooling change');
|
|
144
210
|
if (options.json) {
|
|
145
211
|
write(JSON.stringify(result));
|
|
212
|
+
} else if (result.pending) {
|
|
213
|
+
const batch = result.snapshot?.prewarm?.batches?.find((candidate) => candidate.id === result.batchId);
|
|
214
|
+
const scope = batch?.changes.map((change) => change.name).join(', ') || name;
|
|
215
|
+
const secondsLeft = batch?.applyAt == null ? null : Math.max(0, Math.ceil((batch.applyAt - Date.now()) / 1000));
|
|
216
|
+
const timing = secondsLeft === null ? '' : ` in ${secondsLeft}s`;
|
|
217
|
+
let action;
|
|
218
|
+
if (options.undo) action = 'previous batch configuration staged';
|
|
219
|
+
else if (body.mode === 'pool') action = `${body.minWarm} warm slot(s) staged`;
|
|
220
|
+
else action = 'pre-warming disable staged';
|
|
221
|
+
write(`${name}: ${action}. Pending reload${timing}; not active yet.`);
|
|
222
|
+
if (result.undoId) {
|
|
223
|
+
write(`Cancel while pending / undo after apply (entire batch: ${scope}): ${formatUndoCommand(snapshot.port, name, result.undoId, options.config)}`);
|
|
224
|
+
}
|
|
225
|
+
} else if (result.cancelled) {
|
|
226
|
+
write(`${name}: pending batch cancelled. Active settings were not changed.`);
|
|
146
227
|
} else {
|
|
147
228
|
write(`${name}: ${options.undo ? 'restored previous pooling settings' : body.mode === 'pool' ? `enabled ${body.minWarm} warm slot(s)` : 'disabled pre-warming'}`);
|
|
148
229
|
if (result.undoId) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"binary": "PoolingSecurityHelper.exe",
|
|
4
|
-
"binarySha256": "
|
|
4
|
+
"binarySha256": "db026df3593cf4b9467aa1a6a445f2dd4f3290198becb5bbdbfb484ce6b0ccc0",
|
|
5
5
|
"inputs": {
|
|
6
6
|
"targetFramework": ".NETFramework,Version=v4.6.2",
|
|
7
7
|
"platform": "AnyCPU",
|
|
@@ -28,12 +28,15 @@
|
|
|
28
28
|
"sourceSha256": {
|
|
29
29
|
"AssemblyInfo.cs": "e81e107352fbcb82818d8265dfcdf392eac8d25df37e6d65ca7f02d1e63a6c9a",
|
|
30
30
|
"PoolingSecurityReader.cs": "91620ba5e0b3c6fcc673319f59c96466115a6be74fd8b93808f08f07087b7038",
|
|
31
|
-
"PoolingSecurityHelper.cs": "
|
|
31
|
+
"PoolingSecurityHelper.cs": "1d14768fbf602368f20302bbe382e6d195e12db6008edb3aa95be0de88d75935",
|
|
32
|
+
"PoolingNativeFiles.cs": "1234fd12fb41910981385010a071358476b6a8a4c42827714f3f270f05f23c1d"
|
|
32
33
|
},
|
|
33
|
-
"buildScriptSha256": "
|
|
34
|
+
"buildScriptSha256": "8413c3ea3239a474379103cbf456f52a0a3bc75fcdb23b5f769474abe4a1eea0",
|
|
34
35
|
"referenceSha256": {
|
|
35
36
|
"mscorlib.dll": "7118fe658567606fa0538f7b5a2071e4606756c07e9062e1e5b24fa49e97d1f6",
|
|
36
|
-
"System.dll": "b76822f9c08aff597120e3167b3e0fbda836d6bc6f14bc4bc2eeffab5b2c6125"
|
|
37
|
+
"System.dll": "b76822f9c08aff597120e3167b3e0fbda836d6bc6f14bc4bc2eeffab5b2c6125",
|
|
38
|
+
"System.Core.dll": "774e965a6c25891a96d6d3b838ae10226ed9963a73b09e4be7c2ec275ffb5886",
|
|
39
|
+
"System.Web.Extensions.dll": "7a32bf3263aebed93c3a0847294aebdde15f7427541ed223a4cc4eb93c36d556"
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
}
|
|
Binary file
|