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
package/bin/pooling-config.mjs
CHANGED
|
@@ -1,162 +1,28 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { JsonSpans } from './pooling-editor.mjs';
|
|
5
|
+
import { PoolingConfigError, poolingIOError, poolingConflict, recoveryError } from './pooling-errors.mjs';
|
|
6
|
+
import { PoolingFiles, readPoolingBytes, MAX_CONFIG_BYTES } from './pooling-files.mjs';
|
|
7
|
+
|
|
8
|
+
export { PoolingConfigError } from './pooling-errors.mjs';
|
|
9
|
+
export { recoverPoolingConfig, hasPoolingTransaction, MAX_CONFIG_BYTES } from './pooling-files.mjs';
|
|
7
10
|
export const MAX_MIN_WARM = 32;
|
|
8
11
|
export const MAX_UNDO_ENTRIES = 32;
|
|
9
12
|
export const MAX_UNDO_BYTES = 8 * 1024 * 1024;
|
|
10
|
-
export const
|
|
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
|
-
}
|
|
13
|
+
export const POOLING_SAVE_WARNING =
|
|
14
|
+
'New config copies inherit folder auditing. Custom per-file audit rules may not carry forward.';
|
|
154
15
|
|
|
155
16
|
export class PoolingConfigStore {
|
|
156
17
|
#configPath;
|
|
18
|
+
#files;
|
|
157
19
|
#undos = new Map();
|
|
158
20
|
#undoBytes = 0;
|
|
159
21
|
#execution;
|
|
22
|
+
#pending;
|
|
23
|
+
#publication;
|
|
24
|
+
#closed = false;
|
|
25
|
+
#started = false;
|
|
160
26
|
|
|
161
27
|
constructor(configPath) {
|
|
162
28
|
if (typeof configPath !== 'string' ||
|
|
@@ -165,9 +31,10 @@ export class PoolingConfigStore {
|
|
|
165
31
|
throw new PoolingConfigError(400, 'INVALID_PATH', 'A config file path is required.');
|
|
166
32
|
}
|
|
167
33
|
this.#configPath = resolve(configPath);
|
|
34
|
+
this.#files = new PoolingFiles(this.#configPath);
|
|
168
35
|
}
|
|
169
36
|
|
|
170
|
-
// Internal only: servers can contain credentials. Never serialize
|
|
37
|
+
// Internal only: servers can contain credentials. Never serialize through HTTP.
|
|
171
38
|
snapshot() {
|
|
172
39
|
const current = this.#read();
|
|
173
40
|
return { revision: current.revision, servers: new JsonSpans(current.bytes).servers };
|
|
@@ -177,25 +44,134 @@ export class PoolingConfigStore {
|
|
|
177
44
|
return this.snapshot().revision;
|
|
178
45
|
}
|
|
179
46
|
|
|
47
|
+
pendingSummary() {
|
|
48
|
+
const pending = this.#pending;
|
|
49
|
+
if (!pending) return null;
|
|
50
|
+
return {
|
|
51
|
+
id: pending.batchId, batchId: pending.batchId, generation: pending.generation,
|
|
52
|
+
revision: pending.base.revision, status: 'pending',
|
|
53
|
+
changes: pending.changes.map((change) => ({ ...change })),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
stageApply(request, execution) {
|
|
58
|
+
this.#publication = undefined;
|
|
59
|
+
return this.#execute(() => this.#stageApply(request), execution);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
stageUndo(request, execution) {
|
|
63
|
+
this.#publication = undefined;
|
|
64
|
+
return this.#execute(() => this.#stageUndo(request), execution);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
rejectStage({ generation }, execution) {
|
|
68
|
+
return this.#execute(() => {
|
|
69
|
+
const publication = this.#publication;
|
|
70
|
+
if (!publication ||
|
|
71
|
+
publication.generation !== generation ||
|
|
72
|
+
this.#pending?.generation !== generation) {
|
|
73
|
+
return { ok: true, discarded: false };
|
|
74
|
+
}
|
|
75
|
+
const abandoned = this.#pending;
|
|
76
|
+
const prior = publication.prior;
|
|
77
|
+
if (prior) {
|
|
78
|
+
this.#files.save({
|
|
79
|
+
phase: 'updating', base: prior.base, draft: prior.draft, next: abandoned.draft,
|
|
80
|
+
previous: this.#files.record.previous,
|
|
81
|
+
});
|
|
82
|
+
this.#restoreDraft(prior, abandoned.draft);
|
|
83
|
+
} else {
|
|
84
|
+
this.#files.discard(this.#execution);
|
|
85
|
+
this.#releaseReservation(abandoned);
|
|
86
|
+
}
|
|
87
|
+
this.#pending = prior;
|
|
88
|
+
this.#publication = undefined;
|
|
89
|
+
return { ok: true, discarded: true };
|
|
90
|
+
}, execution);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
commitBatch(request, execution) {
|
|
94
|
+
return this.#execute(() => this.#commitBatch(request), execution, true);
|
|
95
|
+
}
|
|
96
|
+
|
|
180
97
|
apply(request, execution) {
|
|
181
|
-
|
|
98
|
+
const staged = this.stageApply(request, execution);
|
|
99
|
+
if (!staged.pending) return staged;
|
|
100
|
+
const committed = this.commitBatch({ batchId: staged.batchId, generation: staged.generation }, execution);
|
|
101
|
+
const { name, mode, minWarm, undoId } = staged;
|
|
102
|
+
return {
|
|
103
|
+
ok: true, name, mode, ...(minWarm === undefined ? {} : { minWarm }),
|
|
104
|
+
revision: committed.revision, undoId,
|
|
105
|
+
};
|
|
182
106
|
}
|
|
183
107
|
|
|
184
108
|
undo(request, execution) {
|
|
185
|
-
|
|
109
|
+
const staged = this.stageUndo(request, execution);
|
|
110
|
+
if (!staged.pending) return staged;
|
|
111
|
+
const committed = this.commitBatch({ batchId: staged.batchId, generation: staged.generation }, execution);
|
|
112
|
+
const { name, mode, minWarm } = staged;
|
|
113
|
+
return {
|
|
114
|
+
ok: true, name, mode, ...(minWarm === undefined ? {} : { minWarm }),
|
|
115
|
+
revision: committed.revision,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
close() {
|
|
120
|
+
this.#closed = true;
|
|
121
|
+
// All file operations are synchronous in the writer. Its message queue
|
|
122
|
+
// drains entered work before close; pending bytes and the journal remain.
|
|
123
|
+
try { this.#files.close(); } catch (error) { throw poolingIOError(error); }
|
|
186
124
|
}
|
|
187
125
|
|
|
188
|
-
#execute(operation, execution) {
|
|
126
|
+
#execute(operation, execution, committing = false) {
|
|
127
|
+
if (this.#closed) {
|
|
128
|
+
const error = new PoolingConfigError(503, 'WRITER_CLOSED', 'Config store is closed.');
|
|
129
|
+
if (committing) error.commitState = 'not-committed';
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
189
132
|
this.#execution = execution;
|
|
190
133
|
try {
|
|
191
|
-
execution?.check();
|
|
134
|
+
if (!committing) execution?.check();
|
|
192
135
|
return operation();
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const failure = poolingIOError(error);
|
|
138
|
+
if (committing) failure.commitState ??= 'not-committed';
|
|
139
|
+
throw failure;
|
|
193
140
|
} finally {
|
|
194
141
|
this.#execution = undefined;
|
|
195
142
|
}
|
|
196
143
|
}
|
|
197
144
|
|
|
198
|
-
#
|
|
145
|
+
#start() {
|
|
146
|
+
this.#execution?.trace?.record('store-start');
|
|
147
|
+
this.#execution?.check();
|
|
148
|
+
this.#files.acquire();
|
|
149
|
+
if (!this.#started) {
|
|
150
|
+
if (['entered', 'failed'].includes(this.#files.record?.phase)) throw recoveryError();
|
|
151
|
+
this.#files.recover(this.#execution);
|
|
152
|
+
this.#started = true;
|
|
153
|
+
} else if (!this.#pending &&
|
|
154
|
+
['pending', 'prepared'].includes(this.#files.record?.phase)) {
|
|
155
|
+
// A deadline can prevent cleanup. The next fresh operation may discard
|
|
156
|
+
// these owned, unentered bytes; it never replays the failed request.
|
|
157
|
+
this.#files.discard(this.#execution);
|
|
158
|
+
}
|
|
159
|
+
this.#execution?.trace?.record('store-ready');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#read() {
|
|
163
|
+
this.#execution?.check();
|
|
164
|
+
return readPoolingBytes(this.#configPath);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#source(revision) {
|
|
168
|
+
const current = this.#read();
|
|
169
|
+
this.#checkRevision(current.revision, revision);
|
|
170
|
+
if (this.#pending) this.#checkRevision(revision, this.#pending.base.revision);
|
|
171
|
+
return current;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#stageApply(request) {
|
|
199
175
|
this.#validateRequest(request, ['name', 'mode', 'minWarm', 'revision']);
|
|
200
176
|
if (request.mode !== 'pool' &&
|
|
201
177
|
request.mode !== 'isolated') {
|
|
@@ -207,10 +183,8 @@ export class PoolingConfigStore {
|
|
|
207
183
|
}
|
|
208
184
|
this.#validateMinWarm(request.minWarm);
|
|
209
185
|
}
|
|
210
|
-
this.#
|
|
211
|
-
const
|
|
212
|
-
this.#checkRevision(current.revision, request.revision);
|
|
213
|
-
const document = new JsonSpans(current.bytes);
|
|
186
|
+
const current = this.#source(request.revision);
|
|
187
|
+
const document = new JsonSpans(this.#pending?.bytes ?? current.bytes);
|
|
214
188
|
const server = this.#server(document.servers, request.name);
|
|
215
189
|
const changes = new Map();
|
|
216
190
|
if (request.mode === 'pool') {
|
|
@@ -219,45 +193,294 @@ export class PoolingConfigStore {
|
|
|
219
193
|
changes.set('sharing', 'pool');
|
|
220
194
|
changes.set('minWarm', minWarm);
|
|
221
195
|
} else {
|
|
222
|
-
if (Object.hasOwn(server, 'sharing'))
|
|
223
|
-
changes.set('sharing', 'isolated');
|
|
224
|
-
}
|
|
196
|
+
if (Object.hasOwn(server, 'sharing')) changes.set('sharing', 'isolated');
|
|
225
197
|
changes.set('minWarm', undefined);
|
|
226
198
|
}
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
199
|
+
const bytes = document.update(request.name, changes);
|
|
200
|
+
this.#validateCandidate(bytes);
|
|
201
|
+
const state = this.#state(new JsonSpans(bytes).servers, request.name);
|
|
202
|
+
if (bytes.equals(this.#pending?.bytes ?? current.bytes)) {
|
|
203
|
+
// A no-op is not a new acceptance and must not reset the parent's timer.
|
|
204
|
+
if (this.#pending) {
|
|
205
|
+
this.#verifyPending();
|
|
206
|
+
const activeState = this.#state(new JsonSpans(current.bytes).servers, request.name);
|
|
207
|
+
if (JSON.stringify(activeState) !== JSON.stringify(state)) {
|
|
208
|
+
return { ...this.#receipt(state), accepted: false };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
233
211
|
return { ok: true, ...state, revision: current.revision };
|
|
234
212
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
this.#replace(current, next);
|
|
238
|
-
this.#remember(undoId, { name: request.name, bytes: current.bytes, revision });
|
|
239
|
-
return { ok: true, ...state, revision, undoId };
|
|
213
|
+
this.#stage(current, bytes, request.name);
|
|
214
|
+
return this.#receipt(state);
|
|
240
215
|
}
|
|
241
216
|
|
|
242
|
-
#
|
|
217
|
+
#stageUndo(request) {
|
|
243
218
|
this.#validateRequest(request, ['name', 'undoId', 'revision']);
|
|
244
219
|
if (typeof request.undoId !== 'string' ||
|
|
245
220
|
!/^[0-9a-f-]{36}$/.test(request.undoId)) {
|
|
246
221
|
throw new PoolingConfigError(400, 'INVALID_REQUEST', 'A valid undoId is required.');
|
|
247
222
|
}
|
|
223
|
+
const pending = this.#pending;
|
|
224
|
+
if (pending?.undoId === request.undoId) {
|
|
225
|
+
if (!pending.names.has(request.name)) throw this.#undoConflict();
|
|
226
|
+
const current = this.#source(request.revision);
|
|
227
|
+
this.#verifyPending();
|
|
228
|
+
this.#execution?.check();
|
|
229
|
+
this.#files.discard(this.#execution);
|
|
230
|
+
this.#releaseReservation(pending);
|
|
231
|
+
this.#pending = undefined;
|
|
232
|
+
return { ok: true, cancelled: true, batchId: pending.batchId, revision: current.revision };
|
|
233
|
+
}
|
|
248
234
|
const record = this.#undos.get(request.undoId);
|
|
249
235
|
if (!record ||
|
|
250
|
-
record.
|
|
251
|
-
|
|
236
|
+
record.reserved ||
|
|
237
|
+
!record.names.has(request.name)) throw this.#undoConflict();
|
|
238
|
+
if (pending) {
|
|
239
|
+
throw new PoolingConfigError(409, 'PENDING_CONFLICT', 'Cancel or apply the current batch before undoing another batch.');
|
|
252
240
|
}
|
|
253
241
|
this.#checkRevision(request.revision, record.revision);
|
|
254
|
-
this.#
|
|
255
|
-
|
|
256
|
-
this.#checkRevision(current.revision, record.revision);
|
|
242
|
+
const current = this.#source(record.revision);
|
|
243
|
+
this.#validateCandidate(record.bytes);
|
|
257
244
|
const state = this.#state(new JsonSpans(record.bytes).servers, request.name);
|
|
258
|
-
this.#
|
|
259
|
-
this.#
|
|
260
|
-
return
|
|
245
|
+
this.#stage(current, record.bytes, request.name, record.names, request.undoId);
|
|
246
|
+
record.reserved = this.#pending.batchId;
|
|
247
|
+
return this.#receipt(state);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#validateCandidate(bytes) {
|
|
251
|
+
if (bytes.length > MAX_CONFIG_BYTES) {
|
|
252
|
+
throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Updated config would exceed 1 MiB.');
|
|
253
|
+
}
|
|
254
|
+
const document = new JsonSpans(bytes);
|
|
255
|
+
document.validateComplete();
|
|
256
|
+
return document;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#stage(current, bytes, name, names, restoring) {
|
|
260
|
+
this.#start();
|
|
261
|
+
const files = this.#files;
|
|
262
|
+
const prior = this.#pending;
|
|
263
|
+
if (prior) this.#verifyPending();
|
|
264
|
+
const base = prior?.base ?? files.inspect(this.#configPath, this.#execution);
|
|
265
|
+
this.#checkRevision(base.revision, current.revision);
|
|
266
|
+
const destination = prior ? files.paths.next : files.paths.pending;
|
|
267
|
+
let draft;
|
|
268
|
+
try {
|
|
269
|
+
draft = files.stage(destination, this.#configPath, base, bytes, this.#execution);
|
|
270
|
+
files.verify(this.#configPath, base, this.#execution);
|
|
271
|
+
this.#execution?.check();
|
|
272
|
+
if (prior) {
|
|
273
|
+
files.save({ ...files.record, phase: 'updating', next: draft });
|
|
274
|
+
files.move(files.paths.pending, files.paths.old, prior.draft, this.#execution);
|
|
275
|
+
files.move(files.paths.next, files.paths.pending, draft, this.#execution);
|
|
276
|
+
}
|
|
277
|
+
this.#execution?.check();
|
|
278
|
+
this.#execution?.trace?.record('publish-start');
|
|
279
|
+
files.save({
|
|
280
|
+
// Retain the prior native draft until this publication is durable.
|
|
281
|
+
// A later operation can clean it using the recorded identity.
|
|
282
|
+
phase: 'pending', base, draft, next: prior?.draft ?? null,
|
|
283
|
+
previous: files.record?.previous ?? null,
|
|
284
|
+
});
|
|
285
|
+
this.#execution?.trace?.record('publish-end');
|
|
286
|
+
} catch (error) {
|
|
287
|
+
// Before publication, only a returned, verified identity authorizes
|
|
288
|
+
// deleting the new file. Failed helper output is not ownership proof.
|
|
289
|
+
try {
|
|
290
|
+
if (prior && files.record?.phase === 'updating') {
|
|
291
|
+
this.#restoreDraft(prior, draft);
|
|
292
|
+
} else if (draft) {
|
|
293
|
+
files.remove(destination, draft, 1, this.#execution);
|
|
294
|
+
} else if (fs.existsSync(destination)) {
|
|
295
|
+
files.poisoned = true;
|
|
296
|
+
}
|
|
297
|
+
} catch (cleanupError) {
|
|
298
|
+
if (this.#abandoned(cleanupError)) {
|
|
299
|
+
if (draft && !files.poisoned) {
|
|
300
|
+
if (!prior) {
|
|
301
|
+
files.save({
|
|
302
|
+
phase: 'pending', base, draft, next: null,
|
|
303
|
+
previous: files.record?.previous ?? null,
|
|
304
|
+
});
|
|
305
|
+
} else if (files.record?.phase === 'pending') {
|
|
306
|
+
files.save({ ...files.record, next: draft });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
files.poisoned = true;
|
|
312
|
+
throw recoveryError();
|
|
313
|
+
}
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
const included = new Set(names ?? prior?.names ?? []);
|
|
317
|
+
included.add(name);
|
|
318
|
+
const document = new JsonSpans(bytes);
|
|
319
|
+
this.#pending = {
|
|
320
|
+
batchId: prior?.batchId ?? randomUUID(),
|
|
321
|
+
undoId: prior?.undoId ?? randomUUID(),
|
|
322
|
+
generation: randomUUID(),
|
|
323
|
+
base, draft, bytes, before: prior?.before ?? current.bytes, names: included,
|
|
324
|
+
changes: [...included].map((includedName) => this.#state(document.servers, includedName)),
|
|
325
|
+
restoring: prior?.restoring ?? restoring,
|
|
326
|
+
};
|
|
327
|
+
this.#publication = { generation: this.#pending.generation, prior };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#restoreDraft(prior, draft) {
|
|
331
|
+
const files = this.#files;
|
|
332
|
+
const topology = files.topology(this.#execution);
|
|
333
|
+
if (topology.pending?.identity === draft?.identity) files.remove(files.paths.pending, draft, 1, this.#execution);
|
|
334
|
+
if (fs.existsSync(files.paths.old)) {
|
|
335
|
+
files.move(files.paths.old, files.paths.pending, prior.draft, this.#execution);
|
|
336
|
+
}
|
|
337
|
+
if (fs.existsSync(files.paths.next)) files.remove(files.paths.next, draft, 1, this.#execution);
|
|
338
|
+
files.verify(files.paths.pending, prior.draft, this.#execution);
|
|
339
|
+
files.save({
|
|
340
|
+
phase: 'pending', base: prior.base, draft: prior.draft, next: null,
|
|
341
|
+
previous: files.record.previous,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#verifyPending() {
|
|
346
|
+
this.#files.acquire();
|
|
347
|
+
if (this.#files.record?.phase === 'updating') {
|
|
348
|
+
this.#restoreDraft(this.#pending, this.#files.record.next);
|
|
349
|
+
}
|
|
350
|
+
this.#files.verify(this.#configPath, this.#pending.base, this.#execution);
|
|
351
|
+
this.#files.verify(this.#files.paths.pending, this.#pending.draft, this.#execution);
|
|
352
|
+
if (this.#files.record?.phase === 'pending' &&
|
|
353
|
+
this.#files.record.next) {
|
|
354
|
+
for (const path of [this.#files.paths.old, this.#files.paths.next]) {
|
|
355
|
+
if (fs.existsSync(path)) {
|
|
356
|
+
this.#files.verify(path, this.#files.record.next, this.#execution);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const path of [this.#files.paths.old, this.#files.paths.next]) {
|
|
360
|
+
if (fs.existsSync(path)) {
|
|
361
|
+
this.#files.remove(path, this.#files.record.next, 1, this.#execution);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
this.#files.save({ ...this.#files.record, next: null });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#receipt(state) {
|
|
369
|
+
const pending = this.#pending;
|
|
370
|
+
return {
|
|
371
|
+
ok: true, ...state, revision: pending.base.revision, pending: true,
|
|
372
|
+
batchId: pending.batchId, undoId: pending.undoId, generation: pending.generation,
|
|
373
|
+
draftRevision: pending.draft.revision,
|
|
374
|
+
changes: pending.changes.map((change) => ({ ...change })),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#commitBatch(request) {
|
|
379
|
+
this.#publication = undefined;
|
|
380
|
+
const pending = this.#pending;
|
|
381
|
+
const validRequest = request &&
|
|
382
|
+
typeof request === 'object' &&
|
|
383
|
+
!Array.isArray(request) &&
|
|
384
|
+
[Object.prototype, null].includes(Object.getPrototypeOf(request)) &&
|
|
385
|
+
Reflect.ownKeys(request).length === 2 &&
|
|
386
|
+
['batchId', 'generation'].every((key) =>
|
|
387
|
+
Object.hasOwn(Object.getOwnPropertyDescriptor(request, key) ?? {}, 'value'));
|
|
388
|
+
if (!pending ||
|
|
389
|
+
!validRequest ||
|
|
390
|
+
request.batchId !== pending.batchId ||
|
|
391
|
+
request.generation !== pending.generation) {
|
|
392
|
+
throw new PoolingConfigError(409, 'BATCH_CONFLICT', 'The pending batch generation changed.');
|
|
393
|
+
}
|
|
394
|
+
const files = this.#files;
|
|
395
|
+
let entered = false;
|
|
396
|
+
try {
|
|
397
|
+
this.#execution?.check();
|
|
398
|
+
this.#verifyPending();
|
|
399
|
+
const candidate = readPoolingBytes(files.paths.pending);
|
|
400
|
+
this.#validateCandidate(candidate.bytes);
|
|
401
|
+
this.#checkRevision(candidate.revision, pending.draft.revision);
|
|
402
|
+
const previous = files.record.previous;
|
|
403
|
+
files.save({ ...files.record, phase: 'prepared' });
|
|
404
|
+
if (previous) {
|
|
405
|
+
files.remove(files.paths.previous, previous, 1, this.#execution);
|
|
406
|
+
files.save({ ...files.record, previous: null });
|
|
407
|
+
} else if (fs.existsSync(files.paths.previous)) throw poolingConflict();
|
|
408
|
+
this.#verifyPending();
|
|
409
|
+
this.#execution?.check();
|
|
410
|
+
this.#execution?.beginCommit();
|
|
411
|
+
entered = true;
|
|
412
|
+
files.save({ ...files.record, phase: 'entered' });
|
|
413
|
+
files.move(this.#configPath, files.paths.previous, pending.base, this.#execution);
|
|
414
|
+
files.move(files.paths.pending, this.#configPath, pending.draft, this.#execution);
|
|
415
|
+
files.verify(this.#configPath, pending.draft, this.#execution);
|
|
416
|
+
files.verify(files.paths.previous, pending.base, this.#execution);
|
|
417
|
+
files.idle(pending.base);
|
|
418
|
+
this.#finish(pending);
|
|
419
|
+
return {
|
|
420
|
+
ok: true, batchId: pending.batchId, revision: pending.draft.revision,
|
|
421
|
+
changes: pending.changes.map((change) => ({ ...change })),
|
|
422
|
+
commitState: 'committed',
|
|
423
|
+
};
|
|
424
|
+
} catch (error) {
|
|
425
|
+
const failure = poolingIOError(error);
|
|
426
|
+
let commitState = entered ? 'unknown' : 'not-committed';
|
|
427
|
+
if (entered) {
|
|
428
|
+
try {
|
|
429
|
+
const topology = files.topology(this.#execution);
|
|
430
|
+
if (topology.active?.identity === pending.draft.identity &&
|
|
431
|
+
topology.active.revision === pending.draft.revision &&
|
|
432
|
+
topology.active.security === pending.draft.security &&
|
|
433
|
+
topology.previous?.identity === pending.base.identity &&
|
|
434
|
+
!topology.pending) {
|
|
435
|
+
commitState = 'committed';
|
|
436
|
+
} else if (topology.active?.identity === pending.base.identity &&
|
|
437
|
+
topology.active.revision === pending.base.revision &&
|
|
438
|
+
topology.active.security === pending.base.security &&
|
|
439
|
+
!topology.previous) {
|
|
440
|
+
commitState = 'not-committed';
|
|
441
|
+
}
|
|
442
|
+
} catch { /* Failed inspection leaves the outcome unknown. */ }
|
|
443
|
+
}
|
|
444
|
+
if (commitState === 'not-committed') {
|
|
445
|
+
try { files.discard(this.#execution); } catch (cleanupError) {
|
|
446
|
+
if (!this.#abandoned(cleanupError)) files.poisoned = true;
|
|
447
|
+
}
|
|
448
|
+
this.#releaseReservation(pending);
|
|
449
|
+
} else {
|
|
450
|
+
if (pending.restoring) this.#forget(pending.restoring);
|
|
451
|
+
try {
|
|
452
|
+
if (commitState === 'committed') files.idle(pending.base);
|
|
453
|
+
} catch { files.poisoned = true; }
|
|
454
|
+
// Keep the durable checkpoint for fresh, fully verified recovery.
|
|
455
|
+
// This writer cannot retry an outcome it could not establish.
|
|
456
|
+
if (commitState === 'unknown') files.poisoned = true;
|
|
457
|
+
}
|
|
458
|
+
this.#pending = undefined;
|
|
459
|
+
failure.commitState = commitState;
|
|
460
|
+
throw failure;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
#finish(pending) {
|
|
465
|
+
if (pending.restoring) this.#forget(pending.restoring);
|
|
466
|
+
this.#remember(pending.undoId, {
|
|
467
|
+
names: pending.names, bytes: pending.before, revision: pending.draft.revision,
|
|
468
|
+
});
|
|
469
|
+
this.#pending = undefined;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
#releaseReservation(pending) {
|
|
473
|
+
const record = this.#undos.get(pending.restoring);
|
|
474
|
+
if (record?.reserved === pending.batchId) record.reserved = undefined;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#abandoned(error) {
|
|
478
|
+
return error?.code === 'WRITER_CANCELLED' ||
|
|
479
|
+
error?.code === 'WRITER_DEADLINE';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#undoConflict() {
|
|
483
|
+
return new PoolingConfigError(409, 'UNDO_CONFLICT', 'Undo is unavailable. Reread the config.');
|
|
261
484
|
}
|
|
262
485
|
|
|
263
486
|
#validateRequest(request, allowed) {
|
|
@@ -326,215 +549,12 @@ export class PoolingConfigStore {
|
|
|
326
549
|
Number.isSafeInteger(server.minWarm) &&
|
|
327
550
|
server.minWarm >= 1 &&
|
|
328
551
|
server.minWarm <= MAX_MIN_WARM;
|
|
329
|
-
if (hasSafeMinWarm)
|
|
330
|
-
state.minWarm = server.minWarm;
|
|
331
|
-
}
|
|
552
|
+
if (hasSafeMinWarm) state.minWarm = server.minWarm;
|
|
332
553
|
return state;
|
|
333
554
|
}
|
|
334
555
|
|
|
335
|
-
#hash(bytes) {
|
|
336
|
-
return createHash('sha256').update(bytes).digest('hex');
|
|
337
|
-
}
|
|
338
|
-
|
|
339
556
|
#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.`);
|
|
557
|
+
if (actual !== expected) throw poolingConflict();
|
|
538
558
|
}
|
|
539
559
|
|
|
540
560
|
#remember(id, record) {
|
|
@@ -542,12 +562,16 @@ export class PoolingConfigStore {
|
|
|
542
562
|
this.#undoBytes += record.bytes.length;
|
|
543
563
|
while (this.#undos.size > MAX_UNDO_ENTRIES ||
|
|
544
564
|
this.#undoBytes > MAX_UNDO_BYTES) {
|
|
545
|
-
this.#
|
|
565
|
+
const oldest = [...this.#undos].find(([, entry]) => !entry.reserved);
|
|
566
|
+
if (!oldest) throw this.#undoConflict();
|
|
567
|
+
this.#forget(oldest[0]);
|
|
546
568
|
}
|
|
547
569
|
}
|
|
548
570
|
|
|
549
571
|
#forget(id) {
|
|
550
|
-
|
|
572
|
+
const record = this.#undos.get(id);
|
|
573
|
+
if (!record) return;
|
|
574
|
+
this.#undoBytes -= record.bytes.length;
|
|
551
575
|
this.#undos.delete(id);
|
|
552
576
|
}
|
|
553
577
|
}
|