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.
Files changed (40) hide show
  1. package/CHANGELOG.md +424 -0
  2. package/CONTRIBUTING.md +156 -0
  3. package/LICENSE +21 -0
  4. package/README.md +620 -0
  5. package/SECURITY.md +110 -0
  6. package/THIRD_PARTY_NOTICES.txt +366 -0
  7. package/autostart/windows/launcher.vbs +11 -0
  8. package/autostart/windows/register-task.ps1 +63 -0
  9. package/bin/cli.mjs +880 -0
  10. package/bin/config-checks.mjs +62 -0
  11. package/bin/logs.mjs +465 -0
  12. package/bin/mcp-bridge.mjs +2113 -0
  13. package/bin/pooling-config.mjs +553 -0
  14. package/bin/pooling-execution.mjs +91 -0
  15. package/bin/pooling-writer.mjs +169 -0
  16. package/bin/prewarm.mjs +152 -0
  17. package/bin/server-metrics.mjs +69 -0
  18. package/bin/service-control.mjs +124 -0
  19. package/bin/shared-sessions.mjs +1083 -0
  20. package/bin/top.mjs +115 -0
  21. package/bin/update-check.mjs +153 -0
  22. package/bin/windows/PoolingSecurityHelper.build.json +39 -0
  23. package/bin/windows/PoolingSecurityHelper.exe +0 -0
  24. package/bin/windows/src/AssemblyInfo.cs +11 -0
  25. package/bin/windows/src/PoolingSecurityHelper.cs +165 -0
  26. package/bin/windows/src/PoolingSecurityReader.cs +50 -0
  27. package/docs/shared-sessions.md +181 -0
  28. package/examples/servers.example.json +31 -0
  29. package/package.json +77 -0
  30. package/supervisor/bridge-child.mjs +14 -0
  31. package/supervisor/supervise.mjs +172 -0
  32. package/supervisor/supervise.ps1 +23 -0
  33. package/supervisor/supervise.sh +11 -0
  34. package/tools/windows-security-helper/README.md +95 -0
  35. package/tools/windows-security-helper/build.ps1 +262 -0
  36. package/ui/dist/THIRD_PARTY_NOTICES.txt +366 -0
  37. package/ui/dist/assets/index-CIy7uxfd.css +1 -0
  38. package/ui/dist/assets/index-CtaaQsB9.js +8 -0
  39. package/ui/dist/index.html +14 -0
  40. package/ui/dist/third-party-manifest.json +579 -0
@@ -0,0 +1,553 @@
1
+ import fs from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import childProcess from 'node:child_process';
6
+
7
+ export const MAX_MIN_WARM = 32;
8
+ export const MAX_UNDO_ENTRIES = 32;
9
+ export const MAX_UNDO_BYTES = 8 * 1024 * 1024;
10
+ export const MAX_CONFIG_BYTES = 1024 * 1024;
11
+
12
+ const WINDOWS_SECURITY_HELPER = fileURLToPath(new URL('./windows/PoolingSecurityHelper.exe', import.meta.url));
13
+
14
+ export class PoolingConfigError extends Error {
15
+ constructor(statusCode, code, message) {
16
+ super(message);
17
+ this.name = 'PoolingConfigError';
18
+ this.statusCode = statusCode;
19
+ this.code = code;
20
+ }
21
+ }
22
+
23
+ class JsonSpans {
24
+ constructor(bytes) {
25
+ this.text = bytes.toString('utf8');
26
+ this.offset = 0;
27
+ try {
28
+ if (!Buffer.from(this.text).equals(bytes)) {
29
+ throw new Error();
30
+ }
31
+ this.servers = JSON.parse(this.text);
32
+ if (!this.servers ||
33
+ Array.isArray(this.servers) ||
34
+ typeof this.servers !== 'object') {
35
+ throw new Error();
36
+ }
37
+ this.root = this.value(0);
38
+ } catch {
39
+ throw new PoolingConfigError(400, 'INVALID_CONFIG',
40
+ 'Config must be a UTF-8 JSON object without duplicate keys or excessive nesting.');
41
+ }
42
+ }
43
+
44
+ whitespace() {
45
+ while (' \t\r\n'.includes(this.text[this.offset] ?? '\0')) {
46
+ this.offset++;
47
+ }
48
+ }
49
+
50
+ string() {
51
+ const start = this.offset++;
52
+ while (this.text[this.offset] !== '"') {
53
+ this.offset += this.text[this.offset] === '\\' ? 2 : 1;
54
+ }
55
+ this.offset++;
56
+ return JSON.parse(this.text.slice(start, this.offset));
57
+ }
58
+
59
+ value(depth) {
60
+ if (depth > 128) {
61
+ throw new Error();
62
+ }
63
+ this.whitespace();
64
+ const start = this.offset;
65
+ const token = this.text[this.offset];
66
+ const properties = new Map();
67
+ if (token === '{') {
68
+ this.offset++;
69
+ this.whitespace();
70
+ while (this.text[this.offset] !== '}') {
71
+ const keyStart = this.offset;
72
+ const key = this.string();
73
+ const keyEnd = this.offset;
74
+ if (properties.has(key)) {
75
+ throw new Error();
76
+ }
77
+ this.whitespace();
78
+ this.offset++;
79
+ const value = this.value(depth + 1);
80
+ properties.set(key, { keyStart, keyEnd, value });
81
+ this.whitespace();
82
+ if (this.text[this.offset] !== ',') {
83
+ break;
84
+ }
85
+ this.offset++;
86
+ this.whitespace();
87
+ }
88
+ this.offset++;
89
+ } else if (token === '[') {
90
+ this.offset++;
91
+ this.whitespace();
92
+ while (this.text[this.offset] !== ']') {
93
+ this.value(depth + 1);
94
+ this.whitespace();
95
+ if (this.text[this.offset] !== ',') {
96
+ break;
97
+ }
98
+ this.offset++;
99
+ }
100
+ this.offset++;
101
+ } else if (token === '"') {
102
+ this.string();
103
+ } else {
104
+ while (this.offset < this.text.length &&
105
+ !' \t\r\n,}]'.includes(this.text[this.offset])) {
106
+ this.offset++;
107
+ }
108
+ }
109
+ return { start, end: this.offset, properties };
110
+ }
111
+
112
+ update(name, changes) {
113
+ const server = this.root.properties.get(name).value;
114
+ const entries = [...server.properties.values()];
115
+ const edits = [];
116
+ const additions = [];
117
+ for (const [key, value] of changes) {
118
+ const property = server.properties.get(key);
119
+ if (!property) {
120
+ if (value !== undefined) {
121
+ additions.push([key, value]);
122
+ }
123
+ continue;
124
+ }
125
+ if (value === undefined) {
126
+ const index = entries.indexOf(property);
127
+ const previous = entries[index - 1];
128
+ const next = entries[index + 1];
129
+ edits.push({
130
+ start: previous ? previous.value.end : property.keyStart,
131
+ end: previous ? property.value.end : next.keyStart,
132
+ text: '',
133
+ });
134
+ } else if (this.servers[name][key] !== value) {
135
+ edits.push({ start: property.value.start, end: property.value.end, text: JSON.stringify(value) });
136
+ }
137
+ }
138
+ if (additions.length) {
139
+ const first = entries[0];
140
+ const last = entries.at(-1);
141
+ const spacing = this.text.slice(server.start + 1, first.keyStart);
142
+ const colon = this.text.slice(first.keyEnd, first.value.start);
143
+ const text = additions.map(([key, value]) =>
144
+ `,${spacing}${JSON.stringify(key)}${colon}${JSON.stringify(value)}`).join('');
145
+ edits.push({ start: last.value.end, end: last.value.end, text });
146
+ }
147
+ let text = this.text;
148
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
149
+ text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
150
+ }
151
+ return Buffer.from(text);
152
+ }
153
+ }
154
+
155
+ export class PoolingConfigStore {
156
+ #configPath;
157
+ #undos = new Map();
158
+ #undoBytes = 0;
159
+ #execution;
160
+
161
+ constructor(configPath) {
162
+ if (typeof configPath !== 'string' ||
163
+ !configPath ||
164
+ /[\x00-\x1f\x7f]/.test(configPath)) {
165
+ throw new PoolingConfigError(400, 'INVALID_PATH', 'A config file path is required.');
166
+ }
167
+ this.#configPath = resolve(configPath);
168
+ }
169
+
170
+ // Internal only: servers can contain credentials. Never serialize this through HTTP.
171
+ snapshot() {
172
+ const current = this.#read();
173
+ return { revision: current.revision, servers: new JsonSpans(current.bytes).servers };
174
+ }
175
+
176
+ revision() {
177
+ return this.snapshot().revision;
178
+ }
179
+
180
+ apply(request, execution) {
181
+ return this.#execute(() => this.#apply(request), execution);
182
+ }
183
+
184
+ undo(request, execution) {
185
+ return this.#execute(() => this.#undo(request), execution);
186
+ }
187
+
188
+ #execute(operation, execution) {
189
+ this.#execution = execution;
190
+ try {
191
+ execution?.check();
192
+ return operation();
193
+ } finally {
194
+ this.#execution = undefined;
195
+ }
196
+ }
197
+
198
+ #apply(request) {
199
+ this.#validateRequest(request, ['name', 'mode', 'minWarm', 'revision']);
200
+ if (request.mode !== 'pool' &&
201
+ request.mode !== 'isolated') {
202
+ throw new PoolingConfigError(400, 'INVALID_REQUEST', 'Mode must be pool or isolated.');
203
+ }
204
+ if (Object.hasOwn(request, 'minWarm')) {
205
+ if (request.mode !== 'pool') {
206
+ throw new PoolingConfigError(400, 'INVALID_REQUEST', 'minWarm is only valid for pool mode.');
207
+ }
208
+ this.#validateMinWarm(request.minWarm);
209
+ }
210
+ this.#checkRevision(this.#read().revision, request.revision);
211
+ const current = this.#read(true);
212
+ this.#checkRevision(current.revision, request.revision);
213
+ const document = new JsonSpans(current.bytes);
214
+ const server = this.#server(document.servers, request.name);
215
+ const changes = new Map();
216
+ if (request.mode === 'pool') {
217
+ const minWarm = request.minWarm ?? server.minWarm ?? 1;
218
+ this.#validateMinWarm(minWarm);
219
+ changes.set('sharing', 'pool');
220
+ changes.set('minWarm', minWarm);
221
+ } else {
222
+ if (Object.hasOwn(server, 'sharing')) {
223
+ changes.set('sharing', 'isolated');
224
+ }
225
+ changes.set('minWarm', undefined);
226
+ }
227
+ const next = document.update(request.name, changes);
228
+ if (next.length > MAX_CONFIG_BYTES) {
229
+ throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Updated config would exceed 1 MiB.');
230
+ }
231
+ const state = this.#state(new JsonSpans(next).servers, request.name);
232
+ if (next.equals(current.bytes)) {
233
+ return { ok: true, ...state, revision: current.revision };
234
+ }
235
+ const revision = this.#hash(next);
236
+ const undoId = randomUUID();
237
+ this.#replace(current, next);
238
+ this.#remember(undoId, { name: request.name, bytes: current.bytes, revision });
239
+ return { ok: true, ...state, revision, undoId };
240
+ }
241
+
242
+ #undo(request) {
243
+ this.#validateRequest(request, ['name', 'undoId', 'revision']);
244
+ if (typeof request.undoId !== 'string' ||
245
+ !/^[0-9a-f-]{36}$/.test(request.undoId)) {
246
+ throw new PoolingConfigError(400, 'INVALID_REQUEST', 'A valid undoId is required.');
247
+ }
248
+ const record = this.#undos.get(request.undoId);
249
+ if (!record ||
250
+ record.name !== request.name) {
251
+ throw new PoolingConfigError(409, 'UNDO_CONFLICT', 'Undo is unavailable. Reread the config.');
252
+ }
253
+ this.#checkRevision(request.revision, record.revision);
254
+ this.#checkRevision(this.#read().revision, record.revision);
255
+ const current = this.#read(true);
256
+ this.#checkRevision(current.revision, record.revision);
257
+ const state = this.#state(new JsonSpans(record.bytes).servers, request.name);
258
+ this.#replace(current, record.bytes);
259
+ this.#forget(request.undoId);
260
+ return { ok: true, ...state, revision: this.#hash(record.bytes) };
261
+ }
262
+
263
+ #validateRequest(request, allowed) {
264
+ if (!request ||
265
+ Array.isArray(request) ||
266
+ typeof request !== 'object') {
267
+ throw new PoolingConfigError(400, 'INVALID_REQUEST', 'A request object is required.');
268
+ }
269
+ const prototype = Object.getPrototypeOf(request);
270
+ if (prototype !== Object.prototype &&
271
+ prototype !== null) {
272
+ throw new PoolingConfigError(400, 'INVALID_REQUEST', 'A plain request object is required.');
273
+ }
274
+ for (const key of Reflect.ownKeys(request)) {
275
+ const descriptor = Object.getOwnPropertyDescriptor(request, key);
276
+ if (!allowed.includes(key) ||
277
+ !Object.hasOwn(descriptor, 'value')) {
278
+ throw new PoolingConfigError(400, 'INVALID_REQUEST', 'Only pooling request fields are allowed.');
279
+ }
280
+ }
281
+ const validName = typeof request.name === 'string' &&
282
+ request.name.length > 0 &&
283
+ request.name.length <= 256 &&
284
+ !/[\x00-\x1f\x7f-\x9f]/.test(request.name) &&
285
+ !['__proto__', 'constructor', 'prototype'].includes(request.name);
286
+ if (!validName) {
287
+ throw new PoolingConfigError(400, 'INVALID_REQUEST', 'A valid server name is required.');
288
+ }
289
+ if (typeof request.revision !== 'string' ||
290
+ !/^[a-f0-9]{64}$/.test(request.revision)) {
291
+ throw new PoolingConfigError(400, 'INVALID_REQUEST', 'A SHA256 content revision is required.');
292
+ }
293
+ }
294
+
295
+ #validateMinWarm(value) {
296
+ if (!Number.isSafeInteger(value) ||
297
+ value < 1 ||
298
+ value > MAX_MIN_WARM) {
299
+ throw new PoolingConfigError(400, 'INVALID_REQUEST', `minWarm must be an integer from 1 to ${MAX_MIN_WARM}.`);
300
+ }
301
+ }
302
+
303
+ #server(servers, name) {
304
+ if (!Object.hasOwn(servers, name)) {
305
+ throw new PoolingConfigError(404, 'SERVER_NOT_FOUND', 'Server is not configured.');
306
+ }
307
+ const server = servers[name];
308
+ const isStdio = server &&
309
+ !Array.isArray(server) &&
310
+ typeof server === 'object' &&
311
+ (!Object.hasOwn(server, 'type') || server.type === 'stdio') &&
312
+ !Object.hasOwn(server, 'url') &&
313
+ server.sharing !== 'shared' &&
314
+ typeof server.command === 'string' &&
315
+ server.command.trim().length > 0;
316
+ if (!isStdio) {
317
+ throw new PoolingConfigError(400, 'UNSUPPORTED_SERVER', 'Only configured stdio servers support pooling.');
318
+ }
319
+ return server;
320
+ }
321
+
322
+ #state(servers, name) {
323
+ const server = this.#server(servers, name);
324
+ const state = { name, mode: server.sharing === 'pool' ? 'pool' : 'isolated' };
325
+ const hasSafeMinWarm = state.mode === 'pool' &&
326
+ Number.isSafeInteger(server.minWarm) &&
327
+ server.minWarm >= 1 &&
328
+ server.minWarm <= MAX_MIN_WARM;
329
+ if (hasSafeMinWarm) {
330
+ state.minWarm = server.minWarm;
331
+ }
332
+ return state;
333
+ }
334
+
335
+ #hash(bytes) {
336
+ return createHash('sha256').update(bytes).digest('hex');
337
+ }
338
+
339
+ #checkRevision(actual, expected) {
340
+ if (actual !== expected) {
341
+ throw new PoolingConfigError(409, 'REVISION_CONFLICT', 'Config changed. Reread it before applying or undoing.');
342
+ }
343
+ }
344
+
345
+ #read(captureSecurity = false) {
346
+ this.#execution?.check();
347
+ let fd;
348
+ try {
349
+ const pathStat = fs.lstatSync(this.#configPath, { bigint: true });
350
+ if (!pathStat.isFile() ||
351
+ pathStat.nlink !== 1n) {
352
+ throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Config must be a regular file without links.');
353
+ }
354
+ const security = captureSecurity && process.platform === 'win32' ? this.#windowsSecurity() : undefined;
355
+ fd = fs.openSync(this.#configPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
356
+ const stat = fs.fstatSync(fd, { bigint: true });
357
+ if (!stat.isFile() ||
358
+ stat.nlink !== 1n ||
359
+ stat.size > BigInt(MAX_CONFIG_BYTES)) {
360
+ throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Config must be a regular file of at most 1 MiB.');
361
+ }
362
+ if (pathStat.dev !== stat.dev ||
363
+ pathStat.ino !== stat.ino) {
364
+ throw new PoolingConfigError(409, 'REVISION_CONFLICT', 'Config changed. Reread it before applying or undoing.');
365
+ }
366
+ const bytes = fs.readFileSync(fd);
367
+ if (bytes.length > MAX_CONFIG_BYTES) {
368
+ throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Config must be at most 1 MiB.');
369
+ }
370
+ return { bytes, stat, revision: this.#hash(bytes), security };
371
+ } catch (error) {
372
+ throw this.#ioError(error, 'read');
373
+ } finally {
374
+ if (fd !== undefined) {
375
+ try {
376
+ fs.closeSync(fd);
377
+ } catch {
378
+ throw new PoolingConfigError(500, 'IO_ERROR', 'Unable to close the config file.');
379
+ }
380
+ }
381
+ }
382
+ }
383
+
384
+ #checkCurrent(expected) {
385
+ const current = this.#read();
386
+ this.#checkRevision(current.revision, expected.revision);
387
+ const fields = ['dev', 'ino', 'mode', 'uid', 'gid', 'nlink'];
388
+ if (process.platform !== 'win32') fields.push('ctimeNs');
389
+ const changedFields = fields
390
+ .filter((key) => current.stat[key] !== expected.stat[key]);
391
+ if (changedFields.length) {
392
+ throw new PoolingConfigError(409, 'REVISION_CONFLICT',
393
+ `Config file or permissions changed (${changedFields.join(', ')}). Reread the config.`);
394
+ }
395
+ // NTFS metadata updates can change ctime without changing the config or its permissions.
396
+ if (process.platform === 'win32' &&
397
+ current.stat.ctimeNs !== expected.stat.ctimeNs) {
398
+ const security = this.#windowsSecurity();
399
+ if (!expected.security.startsWith('F:') ||
400
+ !security.startsWith('F:')) {
401
+ throw new PoolingConfigError(409, 'REVISION_CONFLICT', 'Config metadata changed and full security state could not be verified. Reread the config.');
402
+ }
403
+ if (security !== expected.security) {
404
+ throw new PoolingConfigError(409, 'REVISION_CONFLICT', 'Config permissions or file attributes changed. Reread the config.');
405
+ }
406
+ }
407
+ }
408
+
409
+ #runWindowsSecurity(operation, environment = {}) {
410
+ const result = childProcess.spawnSync(WINDOWS_SECURITY_HELPER, [operation], {
411
+ shell: false,
412
+ windowsHide: true,
413
+ stdio: ['ignore', 'pipe', 'pipe'],
414
+ encoding: 'utf8',
415
+ timeout: Math.min(10000, this.#execution?.remainingMs() ?? 10000),
416
+ env: { ...process.env, MCP_POOL_SOURCE: this.#configPath, ...environment },
417
+ });
418
+ this.#execution?.check();
419
+ if (result.status === 3) {
420
+ throw new PoolingConfigError(409, 'REVISION_CONFLICT', 'Config permissions or file attributes changed. Reread the config.');
421
+ }
422
+ if (result.error ||
423
+ result.status !== 0) {
424
+ throw new PoolingConfigError(500, 'IO_ERROR', 'Unable to inspect or preserve config file permissions.');
425
+ }
426
+ return result.stdout.trim();
427
+ }
428
+
429
+ #windowsSecurity() {
430
+ const fingerprint = this.#runWindowsSecurity('inspect');
431
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
432
+ const valid = fingerprint.length === 46 &&
433
+ (fingerprint[0] === 'F' || fingerprint[0] === 'P') &&
434
+ fingerprint[1] === ':' && fingerprint[45] === '=' &&
435
+ [...fingerprint.slice(2, 45)].every((character) => alphabet.includes(character));
436
+ if (!valid) {
437
+ throw new PoolingConfigError(500, 'IO_ERROR', 'Unable to inspect config file permissions.');
438
+ }
439
+ return fingerprint;
440
+ }
441
+
442
+ #permissions(files, current) {
443
+ if (process.platform === 'win32') {
444
+ // chmod on Windows cannot preserve a restrictive DACL. Copy it before writing secrets.
445
+ this.#runWindowsSecurity('copy', {
446
+ MCP_POOL_TEMP: files[0].path,
447
+ MCP_POOL_BACKUP: files[1].path,
448
+ MCP_POOL_SECURITY: current.security,
449
+ });
450
+ return;
451
+ }
452
+ const stat = current.stat;
453
+ for (const file of files) {
454
+ const created = fs.fstatSync(file.fd, { bigint: true });
455
+ if (created.uid !== stat.uid ||
456
+ created.gid !== stat.gid) {
457
+ fs.fchownSync(file.fd, Number(stat.uid), Number(stat.gid));
458
+ }
459
+ }
460
+ }
461
+
462
+ #replace(current, bytes) {
463
+ this.#execution?.check();
464
+ if (process.platform === 'win32' &&
465
+ !current.security.startsWith('F:')) {
466
+ throw new PoolingConfigError(403, 'SECURITY_UNAVAILABLE',
467
+ 'Automatic pooling edits require readable audit policy so security can be preserved. No config data was written.');
468
+ }
469
+ const files = [];
470
+ try {
471
+ for (let index = 0; index < 2; index++) {
472
+ this.#execution?.check();
473
+ const path = join(dirname(this.#configPath), `.pooling-${randomUUID()}.tmp`);
474
+ const fd = fs.openSync(path, 'wx', 0o600);
475
+ files.push({ path, fd, owned: true });
476
+ }
477
+ this.#permissions(files, current);
478
+ for (const [index, file] of files.entries()) {
479
+ this.#execution?.check();
480
+ fs.writeFileSync(file.fd, index === 0 ? bytes : current.bytes);
481
+ if (process.platform !== 'win32') {
482
+ fs.fchmodSync(file.fd, Number(current.stat.mode & 0o7777n));
483
+ }
484
+ fs.fsyncSync(file.fd);
485
+ fs.closeSync(file.fd);
486
+ file.fd = undefined;
487
+ }
488
+ this.#checkCurrent(current);
489
+ this.#execution?.check();
490
+ fs.renameSync(files[1].path, `${this.#configPath}.bak`);
491
+ files[1].owned = false;
492
+ // No portable filesystem CAS: an external writer can still race this last check + rename.
493
+ this.#checkCurrent(current);
494
+ this.#execution?.beginCommit();
495
+ fs.renameSync(files[0].path, this.#configPath);
496
+ files[0].owned = false;
497
+ } catch (error) {
498
+ throw this.#ioError(error, 'write');
499
+ } finally {
500
+ this.#cleanup(files);
501
+ }
502
+ }
503
+
504
+ #cleanup(files) {
505
+ let failed = false;
506
+ for (const file of files) {
507
+ if (file.fd !== undefined) {
508
+ try {
509
+ fs.closeSync(file.fd);
510
+ } catch {
511
+ failed = true;
512
+ }
513
+ }
514
+ if (file.owned) {
515
+ try {
516
+ fs.unlinkSync(file.path);
517
+ } catch (error) {
518
+ if (error.code !== 'ENOENT') {
519
+ failed = true;
520
+ }
521
+ }
522
+ }
523
+ }
524
+ if (failed) {
525
+ throw new PoolingConfigError(500, 'IO_ERROR', 'Unable to clean up config transaction files.');
526
+ }
527
+ }
528
+
529
+ #ioError(error, operation) {
530
+ if (error instanceof PoolingConfigError) {
531
+ return error;
532
+ }
533
+ if (operation === 'read' &&
534
+ error.code === 'ENOENT') {
535
+ return new PoolingConfigError(404, 'CONFIG_NOT_FOUND', 'Config file does not exist.');
536
+ }
537
+ return new PoolingConfigError(500, 'IO_ERROR', `Unable to ${operation} the config file.`);
538
+ }
539
+
540
+ #remember(id, record) {
541
+ this.#undos.set(id, record);
542
+ this.#undoBytes += record.bytes.length;
543
+ while (this.#undos.size > MAX_UNDO_ENTRIES ||
544
+ this.#undoBytes > MAX_UNDO_BYTES) {
545
+ this.#forget(this.#undos.keys().next().value);
546
+ }
547
+ }
548
+
549
+ #forget(id) {
550
+ this.#undoBytes -= this.#undos.get(id).bytes.length;
551
+ this.#undos.delete(id);
552
+ }
553
+ }
@@ -0,0 +1,91 @@
1
+ import { PoolingConfigError } from './pooling-config.mjs';
2
+
3
+ export const POOLING_BUDGET_MS = 9000;
4
+
5
+ const ACTIVE = 0;
6
+ const CANCELLED = 1;
7
+ const EXPIRED = 2;
8
+ const COMMITTING = 3;
9
+ const FINISHED = 4;
10
+ const FINISHED_LATE = 5;
11
+ const FINISHED_COMMITTED = 6;
12
+
13
+ export class PoolingExecution {
14
+ constructor(deadline, buffer = new SharedArrayBuffer(4)) {
15
+ this.deadline = deadline;
16
+ this.state = new Int32Array(buffer);
17
+ }
18
+
19
+ check() {
20
+ if (process.hrtime.bigint() >= this.deadline) {
21
+ Atomics.compareExchange(this.state, 0, ACTIVE, EXPIRED);
22
+ }
23
+ const state = Atomics.load(this.state, 0);
24
+ if (state === CANCELLED) {
25
+ throw new PoolingConfigError(408, 'WRITER_CANCELLED', 'Pooling change was cancelled before config commit.');
26
+ }
27
+ if (state === EXPIRED) {
28
+ throw new PoolingConfigError(504, 'WRITER_DEADLINE', 'Pooling change expired before config commit. Reread the config.');
29
+ }
30
+ }
31
+
32
+ remainingMs() {
33
+ this.check();
34
+ return Math.max(1, Math.ceil(Number(this.deadline - process.hrtime.bigint()) / 1e6));
35
+ }
36
+
37
+ beginCommit() {
38
+ this.check();
39
+ if (Atomics.compareExchange(this.state, 0, ACTIVE, COMMITTING) !== ACTIVE) this.check();
40
+ // This is authorization to enter rename, not a cancellable filesystem transaction.
41
+ if (process.hrtime.bigint() >= this.deadline) {
42
+ Atomics.store(this.state, 0, EXPIRED);
43
+ this.check();
44
+ }
45
+ }
46
+
47
+ complete() {
48
+ if (Atomics.load(this.state, 0) === COMMITTING) {
49
+ const late = process.hrtime.bigint() >= this.deadline;
50
+ Atomics.store(this.state, 0, late ? FINISHED_LATE : FINISHED_COMMITTED);
51
+ return late;
52
+ }
53
+ this.check();
54
+ if (Atomics.compareExchange(this.state, 0, ACTIVE, FINISHED) !== ACTIVE) this.check();
55
+ }
56
+
57
+ fail() {
58
+ Atomics.store(this.state, 0, FINISHED);
59
+ }
60
+
61
+ cancel(expired = false) {
62
+ const state = Atomics.compareExchange(this.state, 0, ACTIVE, expired ? EXPIRED : CANCELLED);
63
+ if (state === FINISHED ||
64
+ state === FINISHED_COMMITTED) return undefined;
65
+ if (state === FINISHED_LATE) return this.committedLate();
66
+ if (state === COMMITTING) {
67
+ return new PoolingConfigError(504, 'WRITER_OUTCOME_UNKNOWN',
68
+ 'Config commit already started. Its outcome is not yet known. Do not replay this change; reread the config.');
69
+ }
70
+ try {
71
+ this.check();
72
+ } catch (error) {
73
+ return error;
74
+ }
75
+ }
76
+
77
+ committedLate() {
78
+ return new PoolingConfigError(504, 'WRITER_DEADLINE_COMMITTED',
79
+ 'Config commit finished after its deadline. Do not replay this change; reread the config.');
80
+ }
81
+
82
+ workerFailure() {
83
+ const state = Atomics.load(this.state, 0);
84
+ if (state === FINISHED_LATE) return this.committedLate();
85
+ if (state === COMMITTING ||
86
+ state === FINISHED_COMMITTED) {
87
+ return new PoolingConfigError(500, 'WRITER_OUTCOME_UNKNOWN',
88
+ 'Pooling writer stopped after config commit started. Its outcome is unknown. Do not replay this change; reread the config.');
89
+ }
90
+ }
91
+ }