mcp-pacemaker 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +452 -0
- package/CONTRIBUTING.md +222 -0
- package/LICENSE +21 -0
- package/README.md +621 -0
- package/SECURITY.md +120 -0
- package/THIRD_PARTY_NOTICES.txt +366 -0
- package/autostart/windows/launcher.vbs +11 -0
- package/autostart/windows/register-task.ps1 +63 -0
- package/bin/cli.mjs +880 -0
- package/bin/config-checks.mjs +62 -0
- package/bin/logs.mjs +465 -0
- package/bin/mcp-bridge.mjs +2162 -0
- package/bin/pooling-batch-scheduler.mjs +129 -0
- package/bin/pooling-batches.mjs +270 -0
- package/bin/pooling-config.mjs +577 -0
- package/bin/pooling-editor.mjs +128 -0
- package/bin/pooling-errors.mjs +23 -0
- package/bin/pooling-execution.mjs +104 -0
- package/bin/pooling-files.mjs +541 -0
- package/bin/pooling-trace.mjs +218 -0
- package/bin/pooling-writer.mjs +242 -0
- package/bin/prewarm.mjs +233 -0
- package/bin/server-metrics.mjs +69 -0
- package/bin/service-control.mjs +124 -0
- package/bin/shared-sessions.mjs +1083 -0
- package/bin/top.mjs +115 -0
- package/bin/update-check.mjs +153 -0
- package/bin/windows/PoolingSecurityHelper.build.json +42 -0
- package/bin/windows/PoolingSecurityHelper.exe +0 -0
- package/bin/windows/src/AssemblyInfo.cs +11 -0
- package/bin/windows/src/PoolingNativeFiles.cs +678 -0
- package/bin/windows/src/PoolingSecurityHelper.cs +178 -0
- package/bin/windows/src/PoolingSecurityReader.cs +50 -0
- package/docs/shared-sessions.md +181 -0
- package/examples/servers.example.json +31 -0
- package/package.json +77 -0
- package/supervisor/bridge-child.mjs +14 -0
- package/supervisor/supervise.mjs +172 -0
- package/supervisor/supervise.ps1 +23 -0
- package/supervisor/supervise.sh +11 -0
- package/tools/windows-security-helper/README.md +238 -0
- package/tools/windows-security-helper/build.ps1 +262 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +366 -0
- package/ui/dist/assets/index-CaLPg0c-.css +1 -0
- package/ui/dist/assets/index-CgPruWYi.js +9 -0
- package/ui/dist/index.html +14 -0
- package/ui/dist/third-party-manifest.json +597 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class PoolingConfigError extends Error {
|
|
2
|
+
constructor(statusCode, code, message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'PoolingConfigError';
|
|
5
|
+
this.statusCode = statusCode;
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function poolingIOError(error) {
|
|
11
|
+
if (error instanceof PoolingConfigError) return error;
|
|
12
|
+
return new PoolingConfigError(500, 'IO_ERROR', 'Unable to access config transaction files.');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function poolingConflict() {
|
|
16
|
+
return new PoolingConfigError(409, 'REVISION_CONFLICT',
|
|
17
|
+
'Config content, file identity, or permissions changed. Reread the config.');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function recoveryError() {
|
|
21
|
+
return new PoolingConfigError(409, 'RECOVERY_REQUIRED',
|
|
22
|
+
'Config transaction state could not be verified. No recovery changes were authorized.');
|
|
23
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { PoolingConfigError } from './pooling-errors.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(commitState = 'not-committed') {
|
|
58
|
+
if (commitState === 'unknown') {
|
|
59
|
+
Atomics.store(this.state, 0, COMMITTING);
|
|
60
|
+
} else if (commitState === 'committed') {
|
|
61
|
+
Atomics.store(this.state, 0, process.hrtime.bigint() >= this.deadline
|
|
62
|
+
? FINISHED_LATE : FINISHED_COMMITTED);
|
|
63
|
+
} else {
|
|
64
|
+
Atomics.store(this.state, 0, FINISHED);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
cancel(expired = false) {
|
|
69
|
+
const state = Atomics.compareExchange(this.state, 0, ACTIVE, expired ? EXPIRED : CANCELLED);
|
|
70
|
+
if (state === FINISHED ||
|
|
71
|
+
state === FINISHED_COMMITTED) return undefined;
|
|
72
|
+
if (state === FINISHED_LATE) return this.committedLate();
|
|
73
|
+
if (state === COMMITTING) {
|
|
74
|
+
const error = new PoolingConfigError(504, 'WRITER_OUTCOME_UNKNOWN',
|
|
75
|
+
'Config commit already started. Its outcome is not yet known. Do not replay this change; reread the config.');
|
|
76
|
+
error.commitState = 'unknown';
|
|
77
|
+
return error;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
this.check();
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
committedLate() {
|
|
87
|
+
const error = new PoolingConfigError(504, 'WRITER_DEADLINE_COMMITTED',
|
|
88
|
+
'Config commit finished after its deadline. Do not replay this change; reread the config.');
|
|
89
|
+
error.commitState = 'committed';
|
|
90
|
+
return error;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
workerFailure() {
|
|
94
|
+
const state = Atomics.load(this.state, 0);
|
|
95
|
+
if (state === FINISHED_LATE) return this.committedLate();
|
|
96
|
+
if (state === COMMITTING ||
|
|
97
|
+
state === FINISHED_COMMITTED) {
|
|
98
|
+
const error = new PoolingConfigError(500, 'WRITER_OUTCOME_UNKNOWN',
|
|
99
|
+
'Pooling writer stopped after config commit started. Its outcome is unknown. Do not replay this change; reread the config.');
|
|
100
|
+
error.commitState = state === FINISHED_COMMITTED ? 'committed' : 'unknown';
|
|
101
|
+
return error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import childProcess from 'node:child_process';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { dirname, extname, basename, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { PoolingConfigError, poolingIOError, poolingConflict, recoveryError } from './pooling-errors.mjs';
|
|
7
|
+
import { JsonSpans } from './pooling-editor.mjs';
|
|
8
|
+
import { traceErrorCode } from './pooling-trace.mjs';
|
|
9
|
+
|
|
10
|
+
export const MAX_CONFIG_BYTES = 1024 * 1024;
|
|
11
|
+
export const MAX_TRANSACTION_BYTES = 16384;
|
|
12
|
+
const HELPER = fileURLToPath(new URL('./windows/PoolingSecurityHelper.exe', import.meta.url));
|
|
13
|
+
const HEX = '0123456789abcdef';
|
|
14
|
+
|
|
15
|
+
export function hashBytes(bytes) {
|
|
16
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hex(value, length) {
|
|
20
|
+
return typeof value === 'string' &&
|
|
21
|
+
value.length === length &&
|
|
22
|
+
[...value].every((character) => HEX.includes(character));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function exactKeys(value, keys) {
|
|
26
|
+
return value !== null &&
|
|
27
|
+
typeof value === 'object' &&
|
|
28
|
+
!Array.isArray(value) &&
|
|
29
|
+
Object.keys(value).sort().join(',') === [...keys].sort().join(',');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validDescriptor(value) {
|
|
33
|
+
if (!exactKeys(value, ['identity', 'revision', 'security', 'size']) ||
|
|
34
|
+
!hex(value.revision, 64) ||
|
|
35
|
+
!hex(value.security, 64) ||
|
|
36
|
+
!Number.isSafeInteger(value.size) ||
|
|
37
|
+
value.size < 0 ||
|
|
38
|
+
value.size > MAX_CONFIG_BYTES ||
|
|
39
|
+
typeof value.identity !== 'string') return false;
|
|
40
|
+
const parts = value.identity.split(':');
|
|
41
|
+
return parts.length === 2 &&
|
|
42
|
+
parts.every((part) => part.length > 0 &&
|
|
43
|
+
part.length <= 32 &&
|
|
44
|
+
[...part].every((character) => HEX.includes(character)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function expected(descriptor) {
|
|
48
|
+
return { identity: descriptor.identity, revision: descriptor.revision, security: descriptor.security };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function same(a, b) {
|
|
52
|
+
return Boolean(a && b &&
|
|
53
|
+
a.identity === b.identity &&
|
|
54
|
+
a.revision === b.revision &&
|
|
55
|
+
a.security === b.security &&
|
|
56
|
+
a.size === b.size);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function exists(path) {
|
|
60
|
+
try {
|
|
61
|
+
fs.lstatSync(path);
|
|
62
|
+
return true;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.code === 'ENOENT') return false;
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function boundedRead(path, limit, links = 1) {
|
|
70
|
+
let fd;
|
|
71
|
+
try {
|
|
72
|
+
const before = fs.lstatSync(path, { bigint: true });
|
|
73
|
+
if (!before.isFile() ||
|
|
74
|
+
before.isSymbolicLink() ||
|
|
75
|
+
before.nlink < 1n ||
|
|
76
|
+
before.nlink > BigInt(links) ||
|
|
77
|
+
before.size > BigInt(limit)) {
|
|
78
|
+
throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Config must be a regular, bounded file without links.');
|
|
79
|
+
}
|
|
80
|
+
fd = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
81
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
82
|
+
if (before.dev !== stat.dev ||
|
|
83
|
+
before.ino !== stat.ino ||
|
|
84
|
+
before.size !== stat.size) throw poolingConflict();
|
|
85
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
86
|
+
let size = 0;
|
|
87
|
+
while (size < buffer.length) {
|
|
88
|
+
const count = fs.readSync(fd, buffer, size, buffer.length - size, null);
|
|
89
|
+
if (!count) break;
|
|
90
|
+
size += count;
|
|
91
|
+
}
|
|
92
|
+
if (size > limit) {
|
|
93
|
+
throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Config exceeds its size limit.');
|
|
94
|
+
}
|
|
95
|
+
const after = fs.fstatSync(fd, { bigint: true });
|
|
96
|
+
if (after.size !== stat.size ||
|
|
97
|
+
after.mtimeNs !== stat.mtimeNs ||
|
|
98
|
+
after.ctimeNs !== stat.ctimeNs) throw poolingConflict();
|
|
99
|
+
return { bytes: buffer.subarray(0, size), stat };
|
|
100
|
+
} finally {
|
|
101
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function readPoolingBytes(path) {
|
|
106
|
+
try {
|
|
107
|
+
const result = boundedRead(path, MAX_CONFIG_BYTES);
|
|
108
|
+
return { ...result, revision: hashBytes(result.bytes) };
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code === 'ENOENT') {
|
|
111
|
+
throw new PoolingConfigError(404, 'CONFIG_NOT_FOUND', 'Config file does not exist.');
|
|
112
|
+
}
|
|
113
|
+
throw poolingIOError(error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The two data placements are not atomic. POSIX link+unlink provides exclusive
|
|
118
|
+
// destination creation and retains the inode. Recovery accounts for either
|
|
119
|
+
// intermediate two-link state. Directory fsync is used on POSIX; Windows uses
|
|
120
|
+
// the helper's file flushes, not a power-loss guarantee for directory entries.
|
|
121
|
+
export class PoolingFiles {
|
|
122
|
+
constructor(configPath) {
|
|
123
|
+
this.active = resolve(configPath);
|
|
124
|
+
const extension = extname(this.active);
|
|
125
|
+
const stem = basename(this.active, extension);
|
|
126
|
+
const directory = dirname(this.active);
|
|
127
|
+
this.paths = {
|
|
128
|
+
pending: join(directory, `${stem}.pending${extension}`),
|
|
129
|
+
next: join(directory, `${stem}.pending-next${extension}`),
|
|
130
|
+
old: join(directory, `${stem}.pending-old${extension}`),
|
|
131
|
+
previous: join(directory, `${stem}.previous${extension}`),
|
|
132
|
+
};
|
|
133
|
+
this.directory = `${this.active}.pooling-transaction`;
|
|
134
|
+
this.lock = join(this.directory, 'owner');
|
|
135
|
+
this.record = null;
|
|
136
|
+
this.records = [];
|
|
137
|
+
this.owner = null;
|
|
138
|
+
this.poisoned = false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
syncDirectory(path = dirname(this.active)) {
|
|
142
|
+
if (process.platform === 'win32') return;
|
|
143
|
+
const fd = fs.openSync(path, 'r');
|
|
144
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
helper(action, source, destination, body, execution) {
|
|
148
|
+
execution?.check();
|
|
149
|
+
const options = {
|
|
150
|
+
shell: false,
|
|
151
|
+
windowsHide: true,
|
|
152
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
input: body === undefined ? undefined : JSON.stringify(body),
|
|
155
|
+
maxBuffer: 16384,
|
|
156
|
+
timeout: Math.min(10000, execution?.remainingMs() ?? 10000),
|
|
157
|
+
env: { ...process.env, MCP_POOL_SOURCE: source, MCP_POOL_TEMP: destination ?? '' },
|
|
158
|
+
};
|
|
159
|
+
const trace = execution?.trace;
|
|
160
|
+
const call = trace?.helper();
|
|
161
|
+
trace?.record('helper-start', { action, call, timeoutMs: options.timeout });
|
|
162
|
+
const result = childProcess.spawnSync(HELPER, [action], options);
|
|
163
|
+
trace?.record('helper-end', {
|
|
164
|
+
action, call, helperPid: result.pid ?? 0, exit: result.status, code: traceErrorCode(result.error),
|
|
165
|
+
});
|
|
166
|
+
// The caller must inspect disk state after a late/failed move. Do not turn
|
|
167
|
+
// an entered commit into a supposedly cancelled operation here.
|
|
168
|
+
if (result.status === 3) throw poolingConflict();
|
|
169
|
+
if (result.error ||
|
|
170
|
+
result.status !== 0) {
|
|
171
|
+
execution?.check();
|
|
172
|
+
const lines = String(result.stderr ?? '').split('\n').map((line) => line.trim());
|
|
173
|
+
if (lines.includes('MCPERR nativeError=5')) {
|
|
174
|
+
throw new PoolingConfigError(403, 'ACCESS_DENIED', 'Config source write access or security preservation was refused.');
|
|
175
|
+
}
|
|
176
|
+
if (lines.includes('MCPERR nativeError=32')) {
|
|
177
|
+
throw new PoolingConfigError(409, 'FILE_BUSY', 'A config transaction file is in use.');
|
|
178
|
+
}
|
|
179
|
+
throw new PoolingConfigError(500, 'IO_ERROR', 'Unable to inspect, stage, or move a config transaction file.');
|
|
180
|
+
}
|
|
181
|
+
let descriptor;
|
|
182
|
+
try { descriptor = JSON.parse(result.stdout); } catch { throw poolingIOError(); }
|
|
183
|
+
if (!validDescriptor(descriptor)) throw poolingIOError();
|
|
184
|
+
return descriptor;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
inspect(path, execution, links = 1) {
|
|
188
|
+
execution?.check();
|
|
189
|
+
if (process.platform === 'win32') return this.helper('inspect-access', path, undefined, undefined, execution);
|
|
190
|
+
const { bytes, stat } = boundedRead(path, MAX_CONFIG_BYTES, links);
|
|
191
|
+
return {
|
|
192
|
+
identity: `${stat.dev.toString(16)}:${stat.ino.toString(16)}`,
|
|
193
|
+
revision: hashBytes(bytes),
|
|
194
|
+
security: hashBytes(Buffer.from(`${stat.mode}:${stat.uid}:${stat.gid}`)),
|
|
195
|
+
size: bytes.length,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
verify(path, descriptor, execution, links = 1) {
|
|
200
|
+
if (!same(this.inspect(path, execution, links), descriptor)) throw poolingConflict();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
stage(path, source, descriptor, bytes, execution) {
|
|
204
|
+
execution?.check();
|
|
205
|
+
if (process.platform === 'win32') {
|
|
206
|
+
return this.helper('stage', source, path,
|
|
207
|
+
{ expected: expected(descriptor), bytes: bytes.toString('base64') }, execution);
|
|
208
|
+
}
|
|
209
|
+
this.verify(source, descriptor, execution);
|
|
210
|
+
let input;
|
|
211
|
+
let output;
|
|
212
|
+
try {
|
|
213
|
+
input = fs.openSync(source, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0));
|
|
214
|
+
const stat = fs.fstatSync(input, { bigint: true });
|
|
215
|
+
if ((stat.mode & 0o222n) === 0n) {
|
|
216
|
+
throw new PoolingConfigError(403, 'ACCESS_DENIED', 'Config source write access was refused.');
|
|
217
|
+
}
|
|
218
|
+
const identity = `${stat.dev.toString(16)}:${stat.ino.toString(16)}`;
|
|
219
|
+
if (identity !== descriptor.identity) throw poolingConflict();
|
|
220
|
+
// 0600 prevents inherited POSIX ACL grants from exposing bytes. Owner,
|
|
221
|
+
// group and mode are set before writing. Extended ACL/label cloning is
|
|
222
|
+
// not provided by Node; copies never gain source-external mode grants.
|
|
223
|
+
output = fs.openSync(path, 'wx', 0o600);
|
|
224
|
+
const created = fs.fstatSync(output, { bigint: true });
|
|
225
|
+
if (created.uid !== stat.uid ||
|
|
226
|
+
created.gid !== stat.gid) fs.fchownSync(output, Number(stat.uid), Number(stat.gid));
|
|
227
|
+
// Keep private copies private rather than widening an inherited ACL mask.
|
|
228
|
+
fs.fchmodSync(output, Number(stat.mode & 0o600n));
|
|
229
|
+
this.verify(source, descriptor, execution);
|
|
230
|
+
execution?.check();
|
|
231
|
+
fs.writeFileSync(output, bytes);
|
|
232
|
+
fs.fsyncSync(output);
|
|
233
|
+
} finally {
|
|
234
|
+
if (output !== undefined) fs.closeSync(output);
|
|
235
|
+
if (input !== undefined) fs.closeSync(input);
|
|
236
|
+
}
|
|
237
|
+
this.syncDirectory();
|
|
238
|
+
const staged = this.inspect(path, execution);
|
|
239
|
+
if (staged.revision !== hashBytes(bytes)) throw poolingConflict();
|
|
240
|
+
this.verify(source, descriptor, execution);
|
|
241
|
+
return staged;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
move(source, destination, descriptor, execution) {
|
|
245
|
+
execution?.check();
|
|
246
|
+
if (process.platform === 'win32') {
|
|
247
|
+
const moved = this.helper('move-no-replace', source, destination,
|
|
248
|
+
{ expected: expected(descriptor) }, execution);
|
|
249
|
+
if (!same(moved, descriptor)) throw poolingConflict();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
this.verify(source, descriptor, execution);
|
|
253
|
+
fs.linkSync(source, destination);
|
|
254
|
+
this.syncDirectory();
|
|
255
|
+
this.verify(source, descriptor, execution, 2);
|
|
256
|
+
this.verify(destination, descriptor, execution, 2);
|
|
257
|
+
fs.unlinkSync(source);
|
|
258
|
+
this.syncDirectory();
|
|
259
|
+
this.verify(destination, descriptor, execution);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
remove(path, descriptor, links = 1, execution) {
|
|
263
|
+
this.verify(path, descriptor, execution, links);
|
|
264
|
+
fs.unlinkSync(path);
|
|
265
|
+
this.syncDirectory();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
writeMetadata(path, value) {
|
|
269
|
+
const bytes = Buffer.from(JSON.stringify(value));
|
|
270
|
+
if (bytes.length > MAX_TRANSACTION_BYTES) throw recoveryError();
|
|
271
|
+
const fd = fs.openSync(path, 'wx', 0o600);
|
|
272
|
+
try {
|
|
273
|
+
fs.writeFileSync(fd, bytes);
|
|
274
|
+
fs.fsyncSync(fd);
|
|
275
|
+
} finally { fs.closeSync(fd); }
|
|
276
|
+
this.syncDirectory(dirname(path));
|
|
277
|
+
return this.metadata(path);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
metadata(path) {
|
|
281
|
+
const { bytes, stat } = boundedRead(path, MAX_TRANSACTION_BYTES);
|
|
282
|
+
let value;
|
|
283
|
+
try { value = JSON.parse(bytes.toString('utf8')); } catch { throw recoveryError(); }
|
|
284
|
+
// Records have one canonical encoding; this also rejects duplicate keys,
|
|
285
|
+
// malformed UTF-8 and trailing data without a second permissive parser.
|
|
286
|
+
if (!Buffer.from(JSON.stringify(value)).equals(bytes)) throw recoveryError();
|
|
287
|
+
return { value, hash: hashBytes(bytes), identity: `${stat.dev}:${stat.ino}`, path };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
removeMetadata(record) {
|
|
291
|
+
const current = this.metadata(record.path);
|
|
292
|
+
if (current.hash !== record.hash ||
|
|
293
|
+
current.identity !== record.identity) throw recoveryError();
|
|
294
|
+
fs.unlinkSync(record.path);
|
|
295
|
+
this.syncDirectory(dirname(record.path));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
validateOwner(record) {
|
|
299
|
+
const value = record.value;
|
|
300
|
+
if (!exactKeys(value, ['version', 'pid', 'id']) ||
|
|
301
|
+
value.version !== 1 ||
|
|
302
|
+
!Number.isSafeInteger(value.pid) ||
|
|
303
|
+
value.pid <= 0 ||
|
|
304
|
+
typeof value.id !== 'string' ||
|
|
305
|
+
value.id.length !== 36) throw recoveryError();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
acquire() {
|
|
309
|
+
if (this.owner) {
|
|
310
|
+
const current = this.metadata(join(this.lock, 'process.json'));
|
|
311
|
+
if (current.hash !== this.owner.hash ||
|
|
312
|
+
current.identity !== this.owner.identity) throw recoveryError();
|
|
313
|
+
if (this.poisoned) throw recoveryError();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (!exists(this.directory)) {
|
|
317
|
+
try {
|
|
318
|
+
fs.mkdirSync(this.directory, { mode: 0o700 });
|
|
319
|
+
this.syncDirectory();
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (error.code !== 'EEXIST') throw error;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const stat = fs.lstatSync(this.directory);
|
|
325
|
+
if (!stat.isDirectory() ||
|
|
326
|
+
stat.isSymbolicLink()) throw recoveryError();
|
|
327
|
+
let acquired = false;
|
|
328
|
+
try {
|
|
329
|
+
fs.mkdirSync(this.lock, { mode: 0o700 });
|
|
330
|
+
acquired = true;
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (error.code !== 'EEXIST') throw error;
|
|
333
|
+
}
|
|
334
|
+
if (!acquired) {
|
|
335
|
+
const ownerStat = fs.lstatSync(this.lock);
|
|
336
|
+
if (!ownerStat.isDirectory() ||
|
|
337
|
+
ownerStat.isSymbolicLink()) throw recoveryError();
|
|
338
|
+
const owner = this.metadata(join(this.lock, 'process.json'));
|
|
339
|
+
this.validateOwner(owner);
|
|
340
|
+
try {
|
|
341
|
+
process.kill(owner.value.pid, 0);
|
|
342
|
+
throw new PoolingConfigError(409, 'STORE_BUSY', 'Another config transaction owner is still running.');
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (error.code !== 'ESRCH') throw error;
|
|
345
|
+
}
|
|
346
|
+
// Exclusive takeover gate: all normal entrants refuse an existing owner.
|
|
347
|
+
// A death inside takeover leaves a closed gate and requires inspection,
|
|
348
|
+
// rather than allowing two recovery processes to remove each other's lock.
|
|
349
|
+
try { fs.mkdirSync(join(this.lock, 'takeover')); } catch { throw recoveryError(); }
|
|
350
|
+
this.removeMetadata(owner);
|
|
351
|
+
}
|
|
352
|
+
this.owner = this.writeMetadata(join(this.lock, 'process.json'),
|
|
353
|
+
{ version: 1, pid: process.pid, id: randomUUID() });
|
|
354
|
+
try {
|
|
355
|
+
if (!acquired) fs.rmdirSync(join(this.lock, 'takeover'));
|
|
356
|
+
this.syncDirectory(this.directory);
|
|
357
|
+
this.load();
|
|
358
|
+
} catch (error) {
|
|
359
|
+
this.poisoned = true;
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
load() {
|
|
365
|
+
const names = fs.readdirSync(this.directory);
|
|
366
|
+
const records = [];
|
|
367
|
+
if (names.length > 3) throw recoveryError();
|
|
368
|
+
for (const name of names) {
|
|
369
|
+
if (name === 'owner') continue;
|
|
370
|
+
if (!name.startsWith('state-') ||
|
|
371
|
+
!name.endsWith('.json')) throw recoveryError();
|
|
372
|
+
const record = this.metadata(join(this.directory, name));
|
|
373
|
+
const envelope = record.value;
|
|
374
|
+
if (!exactKeys(envelope, ['version', 'sequence', 'state', 'checksum']) ||
|
|
375
|
+
envelope.version !== 1 ||
|
|
376
|
+
!Number.isSafeInteger(envelope.sequence) ||
|
|
377
|
+
envelope.sequence < 1 ||
|
|
378
|
+
name !== `state-${envelope.sequence}.json` ||
|
|
379
|
+
envelope.checksum !== hashBytes(Buffer.from(JSON.stringify(envelope.state)))) throw recoveryError();
|
|
380
|
+
this.validateState(envelope.state);
|
|
381
|
+
records.push(record);
|
|
382
|
+
}
|
|
383
|
+
records.sort((a, b) => a.value.sequence - b.value.sequence);
|
|
384
|
+
if (records.length === 2 &&
|
|
385
|
+
records[1].value.sequence !== records[0].value.sequence + 1) throw recoveryError();
|
|
386
|
+
this.records = records;
|
|
387
|
+
this.record = records.at(-1)?.value.state ?? null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
validateState(state) {
|
|
391
|
+
if (!exactKeys(state, ['phase', 'base', 'draft', 'next', 'previous']) ||
|
|
392
|
+
!['idle', 'pending', 'updating', 'prepared', 'entered', 'failed'].includes(state.phase)) throw recoveryError();
|
|
393
|
+
for (const key of ['base', 'draft', 'next', 'previous']) {
|
|
394
|
+
if (state[key] !== null &&
|
|
395
|
+
!validDescriptor(state[key])) throw recoveryError();
|
|
396
|
+
}
|
|
397
|
+
if (state.phase === 'idle') {
|
|
398
|
+
if (state.base !== null ||
|
|
399
|
+
state.draft !== null ||
|
|
400
|
+
state.next !== null) throw recoveryError();
|
|
401
|
+
} else if (!state.base ||
|
|
402
|
+
!state.draft ||
|
|
403
|
+
(state.phase === 'updating' && !state.next) ||
|
|
404
|
+
(!['pending', 'updating'].includes(state.phase) && state.next)) throw recoveryError();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
save(state) {
|
|
408
|
+
this.acquire();
|
|
409
|
+
this.validateState(state);
|
|
410
|
+
try {
|
|
411
|
+
// At most two bounded records exist, including the crash interval.
|
|
412
|
+
while (this.records.length > 1) this.removeMetadata(this.records.shift());
|
|
413
|
+
const sequence = (this.records[0]?.value.sequence ?? 0) + 1;
|
|
414
|
+
if (!Number.isSafeInteger(sequence)) throw recoveryError();
|
|
415
|
+
const record = this.writeMetadata(join(this.directory, `state-${sequence}.json`), {
|
|
416
|
+
version: 1, sequence, state,
|
|
417
|
+
checksum: hashBytes(Buffer.from(JSON.stringify(state))),
|
|
418
|
+
});
|
|
419
|
+
this.records.push(record);
|
|
420
|
+
this.record = state;
|
|
421
|
+
while (this.records.length > 1) this.removeMetadata(this.records.shift());
|
|
422
|
+
} catch (error) {
|
|
423
|
+
this.poisoned = true;
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
idle(previous = this.record?.previous ?? null) {
|
|
429
|
+
this.save({ phase: 'idle', base: null, draft: null, next: null, previous });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
topology(execution) {
|
|
433
|
+
const result = {};
|
|
434
|
+
for (const [key, path] of Object.entries({ active: this.active, ...this.paths })) {
|
|
435
|
+
result[key] = exists(path) ? this.inspect(path, execution, process.platform === 'win32' ? 1 : 2) : null;
|
|
436
|
+
}
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
discard(execution) {
|
|
441
|
+
this.acquire();
|
|
442
|
+
const state = this.record;
|
|
443
|
+
const topology = this.topology(execution);
|
|
444
|
+
const owned = [state?.draft, state?.next].filter(Boolean);
|
|
445
|
+
// Validate the entire set before deleting anything. A failed helper can
|
|
446
|
+
// leave an unknown candidate; its mere filename is never cleanup authority.
|
|
447
|
+
for (const key of ['pending', 'next', 'old']) {
|
|
448
|
+
if (topology[key] &&
|
|
449
|
+
!owned.some((descriptor) => same(descriptor, topology[key]))) throw recoveryError();
|
|
450
|
+
}
|
|
451
|
+
for (const key of ['pending', 'next', 'old']) {
|
|
452
|
+
if (topology[key]) this.remove(this.paths[key], topology[key], 2, execution);
|
|
453
|
+
}
|
|
454
|
+
this.idle(topology.previous ? state.previous : null);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
recover(execution) {
|
|
458
|
+
execution?.check();
|
|
459
|
+
this.acquire();
|
|
460
|
+
if (!this.record) {
|
|
461
|
+
const topology = this.topology(execution);
|
|
462
|
+
if (topology.pending ||
|
|
463
|
+
topology.next ||
|
|
464
|
+
topology.old ||
|
|
465
|
+
topology.previous) throw recoveryError();
|
|
466
|
+
return { outcome: 'none' };
|
|
467
|
+
}
|
|
468
|
+
const state = this.record;
|
|
469
|
+
if (state.phase === 'failed') throw recoveryError();
|
|
470
|
+
const topology = this.topology(execution);
|
|
471
|
+
if (!same(topology.previous, state.previous) &&
|
|
472
|
+
(topology.previous || state.previous) &&
|
|
473
|
+
state.phase !== 'entered' &&
|
|
474
|
+
!(state.phase === 'prepared' && !topology.previous)) throw recoveryError();
|
|
475
|
+
if (state.phase === 'idle') {
|
|
476
|
+
if (topology.pending ||
|
|
477
|
+
topology.next ||
|
|
478
|
+
topology.old) throw recoveryError();
|
|
479
|
+
return { outcome: 'none' };
|
|
480
|
+
}
|
|
481
|
+
if (state.phase !== 'entered') {
|
|
482
|
+
this.discard(execution);
|
|
483
|
+
return { outcome: 'discarded', commitState: 'not-committed' };
|
|
484
|
+
}
|
|
485
|
+
if (topology.next ||
|
|
486
|
+
topology.old ||
|
|
487
|
+
(topology.previous && !same(topology.previous, state.base)) ||
|
|
488
|
+
(topology.pending && !same(topology.pending, state.draft))) throw recoveryError();
|
|
489
|
+
const candidatePath = topology.pending ? this.paths.pending : this.active;
|
|
490
|
+
const candidate = boundedRead(candidatePath, MAX_CONFIG_BYTES, 2);
|
|
491
|
+
new JsonSpans(candidate.bytes).validateComplete();
|
|
492
|
+
if (same(topology.active, state.base)) {
|
|
493
|
+
if (!topology.pending) throw recoveryError();
|
|
494
|
+
if (topology.previous) {
|
|
495
|
+
if (process.platform === 'win32') throw recoveryError();
|
|
496
|
+
this.remove(this.active, state.base, 2, execution);
|
|
497
|
+
} else {
|
|
498
|
+
this.move(this.active, this.paths.previous, state.base, execution);
|
|
499
|
+
}
|
|
500
|
+
} else if (topology.active &&
|
|
501
|
+
!same(topology.active, state.draft)) throw recoveryError();
|
|
502
|
+
if (!same(this.inspect(this.paths.previous, execution), state.base)) throw recoveryError();
|
|
503
|
+
if (!exists(this.active)) {
|
|
504
|
+
this.move(this.paths.pending, this.active, state.draft, execution);
|
|
505
|
+
} else {
|
|
506
|
+
this.verify(this.active, state.draft, execution, 2);
|
|
507
|
+
if (exists(this.paths.pending)) {
|
|
508
|
+
if (process.platform === 'win32') throw recoveryError();
|
|
509
|
+
this.remove(this.paths.pending, state.draft, 2, execution);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
this.verify(this.active, state.draft, execution);
|
|
513
|
+
this.idle(state.base);
|
|
514
|
+
return { outcome: 'committed', commitState: 'committed', revision: state.draft.revision };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
close() {
|
|
518
|
+
if (!this.owner) return;
|
|
519
|
+
this.removeMetadata(this.owner);
|
|
520
|
+
this.owner = null;
|
|
521
|
+
fs.rmdirSync(this.lock);
|
|
522
|
+
this.syncDirectory(this.directory);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export function hasPoolingTransaction(configPath) {
|
|
527
|
+
return exists(`${resolve(configPath)}.pooling-transaction`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function recoverPoolingConfig(configPath) {
|
|
531
|
+
const files = new PoolingFiles(configPath);
|
|
532
|
+
try {
|
|
533
|
+
return files.recover();
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if (error instanceof PoolingConfigError &&
|
|
536
|
+
error.code === 'STORE_BUSY') throw error;
|
|
537
|
+
throw recoveryError();
|
|
538
|
+
} finally {
|
|
539
|
+
try { files.close(); } catch { throw recoveryError(); }
|
|
540
|
+
}
|
|
541
|
+
}
|