mcp-pacemaker 2.0.2 → 2.1.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 +171 -1
- package/CONTRIBUTING.md +46 -13
- package/README.md +109 -524
- package/THIRD_PARTY_NOTICES.txt +1 -28
- package/autostart/windows/account-task.ps1 +167 -0
- package/autostart/windows/inspect-task.ps1 +42 -0
- package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
- package/autostart/windows/legacy-access.ps1 +359 -0
- package/autostart/windows/legacy-task.ps1 +301 -0
- package/autostart/windows/register-task.ps1 +11 -2
- package/bin/account-task-adapter.mjs +47 -0
- package/bin/account-upgrade-controller.mjs +198 -0
- package/bin/account-upgrade-worker.mjs +141 -0
- package/bin/account-worker-session.mjs +215 -0
- package/bin/cli-dispatch.mjs +154 -0
- package/bin/cli-main.mjs +1063 -0
- package/bin/cli-selection.mjs +43 -0
- package/bin/cli.mjs +7 -876
- package/bin/dependency-lock.json +642 -0
- package/bin/legacy-1.3.json +32 -0
- package/bin/legacy-access.mjs +73 -0
- package/bin/legacy-installation.mjs +273 -0
- package/bin/legacy-task.mjs +76 -0
- package/bin/legacy-upgrade.mjs +527 -0
- package/bin/managed-runtime.mjs +170 -0
- package/bin/managed-state.mjs +257 -0
- package/bin/managed-upgrade.mjs +581 -0
- package/bin/mcp-bridge.mjs +150 -4
- package/bin/pooling-batches.mjs +13 -0
- package/bin/pooling-writer.mjs +4 -0
- package/bin/prewarm.mjs +59 -25
- package/bin/runtime-context.mjs +100 -0
- package/bin/service-control.mjs +32 -4
- package/bin/stable-launcher.mjs +54 -0
- package/bin/task-only-controller.mjs +226 -0
- package/bin/task-scope.mjs +65 -0
- package/bin/task-transaction-protocol.mjs +173 -0
- package/bin/top.mjs +22 -10
- package/bin/update-check.mjs +13 -2
- package/bin/upgrade-autostart.mjs +47 -0
- package/bin/upgrade-capability.json +9 -0
- package/bin/upgrade-dependencies.mjs +183 -0
- package/bin/upgrade-package.mjs +205 -0
- package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
- package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
- package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
- package/bin/windows-legacy/src/LegacyNative.cs +832 -0
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +961 -0
- package/bin/windows-legacy-process.mjs +248 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
- package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
- package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
- package/bin/windows-process-lifetime.mjs +218 -0
- package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
- package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
- package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
- package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
- package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
- package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
- package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
- package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
- package/bin/windows-task-channel.mjs +335 -0
- package/docs/account-upgrades.md +218 -0
- package/docs/compatibility.md +39 -0
- package/docs/configuration.md +146 -0
- package/docs/dashboards.md +125 -0
- package/docs/execution-context.md +119 -0
- package/docs/http-api.md +47 -0
- package/docs/install.md +52 -0
- package/docs/operations.md +99 -0
- package/docs/upgrades.md +324 -0
- package/package.json +19 -5
- package/supervisor/supervise.mjs +226 -40
- package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
- package/tools/windows-legacy/README.md +251 -0
- package/tools/windows-legacy/build.ps1 +122 -0
- package/tools/windows-legacy/inventory.mjs +92 -0
- package/tools/windows-process-lifetime/README.md +119 -0
- package/tools/windows-process-lifetime/build.ps1 +122 -0
- package/tools/windows-process-lifetime/inventory.mjs +86 -0
- package/tools/windows-task-channel/README.md +329 -0
- package/tools/windows-task-channel/build.ps1 +107 -0
- package/tools/windows-task-channel/inventory.mjs +117 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
- package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
- package/ui/dist/assets/index-PVcuDNrN.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/third-party-manifest.json +37 -25
- package/ui/dist/assets/index-CaLPg0c-.css +0 -1
package/bin/mcp-bridge.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
32
32
|
import { dirname, resolve, extname } from 'node:path';
|
|
33
33
|
import { homedir } from 'node:os';
|
|
34
34
|
import { createInterface } from 'node:readline';
|
|
35
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
35
36
|
import { checkServerPaths, checkReservedName } from './config-checks.mjs';
|
|
36
37
|
import { ServerMetrics } from './server-metrics.mjs';
|
|
37
38
|
import { MAX_MIN_WARM, hasPoolingTransaction, recoverPoolingConfig } from './pooling-config.mjs';
|
|
@@ -39,6 +40,28 @@ import { PoolingBatches } from './pooling-batches.mjs';
|
|
|
39
40
|
import { POOLING_BUDGET_MS } from './pooling-execution.mjs';
|
|
40
41
|
import { poolingTrace } from './pooling-trace.mjs';
|
|
41
42
|
import { SharedSessionManager, SHARED_DEFAULTS } from './shared-sessions.mjs';
|
|
43
|
+
import { ensureWindowsProcessLifetime } from './windows-process-lifetime.mjs';
|
|
44
|
+
import { PoolingFiles } from './pooling-files.mjs';
|
|
45
|
+
import { requireUserRuntimeContext } from './runtime-context.mjs';
|
|
46
|
+
|
|
47
|
+
await requireUserRuntimeContext();
|
|
48
|
+
// Assign the bridge before config can start pooled, shared, stdio or auth children.
|
|
49
|
+
const windowsLifetime = await ensureWindowsProcessLifetime();
|
|
50
|
+
if (windowsLifetime &&
|
|
51
|
+
process.connected &&
|
|
52
|
+
process.env.MCP_PACEMAKER_MANAGED_INSTANCE) {
|
|
53
|
+
await new Promise((resolveObserved, reject) => {
|
|
54
|
+
const timer = setTimeout(() => reject(new Error('Supervisor did not verify process lifetime ownership.')), 8000);
|
|
55
|
+
const observed = message => {
|
|
56
|
+
if (message !== 'upgrade-lifetime-observed') return;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
process.off('message', observed);
|
|
59
|
+
resolveObserved();
|
|
60
|
+
};
|
|
61
|
+
process.on('message', observed);
|
|
62
|
+
process.send({ type: 'upgrade-lifetime', lifetime: windowsLifetime });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
42
65
|
|
|
43
66
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
44
67
|
const argv = process.argv.slice(2);
|
|
@@ -53,14 +76,25 @@ const defaultConfig = existsSync(localConfig) || hasPoolingTransaction(localConf
|
|
|
53
76
|
? resolve(process.cwd(), 'servers.json')
|
|
54
77
|
: resolve(HOME, 'servers.json');
|
|
55
78
|
const CONFIG = resolve(process.cwd(), getArg('--config', defaultConfig));
|
|
56
|
-
|
|
79
|
+
let PORT = Number(getArg('--port', '8791'));
|
|
57
80
|
const HOST = getArg('--host', '127.0.0.1');
|
|
81
|
+
let upgradeHeld = process.env.MCP_PACEMAKER_UPGRADE_PENDING === '1';
|
|
82
|
+
let upgradeRequests = 0;
|
|
83
|
+
let upgradeCalls = 0;
|
|
84
|
+
let upgradeTraffic = false;
|
|
85
|
+
if (PORT === 0 &&
|
|
86
|
+
(!process.connected || !process.env.MCP_PACEMAKER_MANAGED_INSTANCE || HOST !== '127.0.0.1')) {
|
|
87
|
+
throw new Error('Port 0 requires a managed loopback startup with actual-port readiness.');
|
|
88
|
+
}
|
|
58
89
|
const VERSION = (() => { try { return JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8')).version; } catch { return '0'; } })();
|
|
59
90
|
// Base working directory for relative server commands/args. Defaults to the config's folder.
|
|
60
91
|
const BASE_CWD = resolve(getArg('--cwd', dirname(CONFIG)));
|
|
61
92
|
|
|
62
93
|
/** @type {Record<string, any>} */
|
|
63
|
-
if (hasPoolingTransaction(CONFIG))
|
|
94
|
+
if (hasPoolingTransaction(CONFIG)) {
|
|
95
|
+
if (upgradeHeld) throw new Error('Unresolved config transaction blocks managed startup.');
|
|
96
|
+
recoverPoolingConfig(CONFIG);
|
|
97
|
+
}
|
|
64
98
|
const servers = JSON.parse(readFileSync(CONFIG, 'utf8'));
|
|
65
99
|
const poolingConfig = new PoolingBatches({
|
|
66
100
|
configPath: CONFIG,
|
|
@@ -416,6 +450,13 @@ function handlePoolingChange(encodedName, req, res) {
|
|
|
416
450
|
trace?.record('body-end', { bytes });
|
|
417
451
|
clearTimeout(bodyTimer);
|
|
418
452
|
if (rejected) return;
|
|
453
|
+
// A body that arrives after an upgrade closed admission must not start config work while
|
|
454
|
+
// the bridge is being stopped.
|
|
455
|
+
if (upgradeHeld ||
|
|
456
|
+
shuttingDown) {
|
|
457
|
+
reply(503, { error: 'Managed upgrade in progress; no change was submitted.' });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
419
460
|
let body;
|
|
420
461
|
let name;
|
|
421
462
|
try {
|
|
@@ -1159,6 +1200,7 @@ function spawnWarm(name) {
|
|
|
1159
1200
|
return child;
|
|
1160
1201
|
}
|
|
1161
1202
|
function refillPool(name) {
|
|
1203
|
+
if (upgradeHeld) return;
|
|
1162
1204
|
const target = poolTarget(name);
|
|
1163
1205
|
if (!target ||
|
|
1164
1206
|
shuttingDown) {
|
|
@@ -1224,7 +1266,8 @@ const resumable = new Map(); // sessionId -> { server, initialize, at }
|
|
|
1224
1266
|
const resuming = new Map(); // sessionId -> handshake promise, before any asynchronous admission
|
|
1225
1267
|
|
|
1226
1268
|
function saveResumable() {
|
|
1227
|
-
if (!RESUME_ENABLED
|
|
1269
|
+
if (!RESUME_ENABLED ||
|
|
1270
|
+
upgradeHeld) return;
|
|
1228
1271
|
try { writeFileSync(RESUME_FILE, JSON.stringify(Object.fromEntries(resumable))); } catch { /* best effort */ }
|
|
1229
1272
|
}
|
|
1230
1273
|
// Records past the TTL are dropped. Without this the window was only ever applied when the file
|
|
@@ -1376,6 +1419,7 @@ function ensureRecycleTimer() {
|
|
|
1376
1419
|
// Check often enough to honour the shortest configured period, but never busier than needed.
|
|
1377
1420
|
const tick = Math.max(200, Math.min(30_000, (Math.min(...periods) * 60_000) / 2));
|
|
1378
1421
|
recycleTimer = setInterval(() => {
|
|
1422
|
+
if (upgradeHeld) return;
|
|
1379
1423
|
const now = Date.now();
|
|
1380
1424
|
for (const name of Object.keys(servers)) {
|
|
1381
1425
|
const mins = recycleMinutesFor(name);
|
|
@@ -1515,6 +1559,7 @@ if (process.env.MCP_CONFIG_WATCH !== '0') {
|
|
|
1515
1559
|
watcher = watch(CONFIG, () => {
|
|
1516
1560
|
clearTimeout(timer);
|
|
1517
1561
|
timer = setTimeout(() => {
|
|
1562
|
+
if (upgradeHeld) { rearm(); return; }
|
|
1518
1563
|
poolingConfig.reloadSaved('file changed')
|
|
1519
1564
|
.catch(() => log('config watcher could not reload the saved configuration'))
|
|
1520
1565
|
.finally(rearm);
|
|
@@ -1540,12 +1585,14 @@ if (process.env.MCP_CONFIG_WATCH !== '0') {
|
|
|
1540
1585
|
const HEALTH_INTERVAL_MS = Number(process.env.MCP_HEALTH_INTERVAL_MS || 0);
|
|
1541
1586
|
|
|
1542
1587
|
function probeServer(name) {
|
|
1588
|
+
if (upgradeHeld) return;
|
|
1543
1589
|
const def = servers[name];
|
|
1544
1590
|
if (!def || def.type !== 'http' || !def.url) return;
|
|
1545
1591
|
// A server the client authenticates cannot be probed usefully: the bridge holds no credential
|
|
1546
1592
|
// for it, so every probe would 401 and report a working server as broken.
|
|
1547
1593
|
if (!resolveAuth(def) && !def.headers) return;
|
|
1548
1594
|
stat(name).lastProbe = Date.now();
|
|
1595
|
+
upgradeRequests++;
|
|
1549
1596
|
const body = JSON.stringify({
|
|
1550
1597
|
jsonrpc: '2.0', id: `probe-${Date.now()}`, method: 'initialize',
|
|
1551
1598
|
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'mcp-pacemaker-health', version: VERSION } },
|
|
@@ -1568,7 +1615,8 @@ function probeServer(name) {
|
|
|
1568
1615
|
r.setTimeout(10_000, () => { r.destroy(new Error('timed out')); });
|
|
1569
1616
|
r.end(body);
|
|
1570
1617
|
});
|
|
1571
|
-
})().catch((e) => noteFailure(name, `health probe: ${e.message}`))
|
|
1618
|
+
})().catch((e) => noteFailure(name, `health probe: ${e.message}`))
|
|
1619
|
+
.finally(() => { upgradeRequests--; });
|
|
1572
1620
|
}
|
|
1573
1621
|
|
|
1574
1622
|
if (HEALTH_INTERVAL_MS > 0) {
|
|
@@ -1600,6 +1648,7 @@ if (TOKEN_REFRESH_LEAD_MS > 0) {
|
|
|
1600
1648
|
// Check often enough to act inside the lead window, but no busier than that requires.
|
|
1601
1649
|
const tick = Math.max(500, Math.min(60_000, TOKEN_REFRESH_LEAD_MS / 2));
|
|
1602
1650
|
setInterval(() => {
|
|
1651
|
+
if (upgradeHeld) return;
|
|
1603
1652
|
const now = Date.now();
|
|
1604
1653
|
for (const [name, def] of Object.entries(servers)) {
|
|
1605
1654
|
const key = authCacheKey(def);
|
|
@@ -2046,6 +2095,25 @@ function proxyHttp(name, def, req, res) {
|
|
|
2046
2095
|
const server = http.createServer((req, res) => {
|
|
2047
2096
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
2048
2097
|
const [name, kind] = url.pathname.split('/').filter(Boolean);
|
|
2098
|
+
const readOnlyStatus = (name === 'status' && !kind) ||
|
|
2099
|
+
(name === 'api' && kind === 'status');
|
|
2100
|
+
if (!readOnlyStatus) {
|
|
2101
|
+
if (upgradeHeld) {
|
|
2102
|
+
res.writeHead(503, { 'Retry-After': '2' }).end('Managed upgrade in progress; retry after the restart.');
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
if (!['api', 'ui', '.well-known'].includes(name)) {
|
|
2106
|
+
// A GET is a listening stream that lasts as long as its session; anything else is a call.
|
|
2107
|
+
const call = req.method !== 'GET';
|
|
2108
|
+
upgradeRequests++;
|
|
2109
|
+
if (call) upgradeCalls++;
|
|
2110
|
+
upgradeTraffic = true;
|
|
2111
|
+
res.once('close', () => {
|
|
2112
|
+
upgradeRequests--;
|
|
2113
|
+
if (call) upgradeCalls--;
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2049
2117
|
|
|
2050
2118
|
if (name === 'status' && !kind) {
|
|
2051
2119
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -2133,12 +2201,90 @@ server.keepAliveTimeout = 65_000;
|
|
|
2133
2201
|
server.headersTimeout = 70_000;
|
|
2134
2202
|
|
|
2135
2203
|
server.listen(PORT, HOST, () => {
|
|
2204
|
+
PORT = server.address().port;
|
|
2136
2205
|
try { writeFileSync(NONCE_FILE, ADMIN_NONCE); } catch { /* noop */ }
|
|
2137
2206
|
log(`listening on http://${HOST}:${PORT} (config ${CONFIG}, base cwd ${BASE_CWD})`);
|
|
2138
2207
|
log(`dashboard: http://${HOST}:${PORT}/ui · api /api/status|/api/events|/api/logs · admin nonce -> ${NONCE_FILE}`);
|
|
2139
2208
|
for (const [n, d] of Object.entries(servers)) {
|
|
2140
2209
|
log(` ${n} -> ${d.type === 'http' ? `http proxy ${d.url}` : `stdio /${n}/sse | /${n}/mcp`}`);
|
|
2141
2210
|
}
|
|
2211
|
+
if (process.connected) process.send({
|
|
2212
|
+
type: 'upgrade-ready', protocol: 1, port: PORT, version: VERSION, instanceId, held: upgradeHeld,
|
|
2213
|
+
lifetime: windowsLifetime,
|
|
2214
|
+
});
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
function upgradeState() {
|
|
2218
|
+
const pooling = poolingConfig.upgradeState();
|
|
2219
|
+
const unsafe = pooling.unsafe ||
|
|
2220
|
+
hasPoolingTransaction(CONFIG);
|
|
2221
|
+
const calls = upgradeCalls > 0 ||
|
|
2222
|
+
resuming.size > 0 ||
|
|
2223
|
+
tokenInFlight.size > 0;
|
|
2224
|
+
const busy = pooling.busy ||
|
|
2225
|
+
calls ||
|
|
2226
|
+
upgradeRequests > 0 ||
|
|
2227
|
+
sessions.size > 0 ||
|
|
2228
|
+
httpSessions.size > 0;
|
|
2229
|
+
return { unsafe, busy, calls, configBusy: pooling.busy, held: upgradeHeld, traffic: upgradeTraffic, port: PORT, version: VERSION, instanceId };
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// Quiesce waits for calls and config work, never for open sessions. A staged pooling batch
|
|
2233
|
+
// still commits on its own timer while admission is held.
|
|
2234
|
+
function draining() {
|
|
2235
|
+
const state = upgradeState();
|
|
2236
|
+
return state.unsafe ||
|
|
2237
|
+
state.configBusy ||
|
|
2238
|
+
state.calls;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
let upgradeControlBusy = false;
|
|
2242
|
+
process.on('message', async (message) => {
|
|
2243
|
+
if (message?.type !== 'upgrade-control') return;
|
|
2244
|
+
const reply = (result) => {
|
|
2245
|
+
if (process.connected) process.send({ type: 'upgrade-result', requestId: message.requestId, ...result });
|
|
2246
|
+
};
|
|
2247
|
+
if (upgradeControlBusy) { reply({ ok: false, error: 'Admission operation already in progress.' }); return; }
|
|
2248
|
+
upgradeControlBusy = true;
|
|
2249
|
+
try {
|
|
2250
|
+
if (message.action === 'quiesce') {
|
|
2251
|
+
upgradeHeld = true;
|
|
2252
|
+
// Admission is closed now. Calls in progress and config work get up to eight seconds;
|
|
2253
|
+
// calls still running at the restart end with their sessions. Config work that is still
|
|
2254
|
+
// unfinished blocks the upgrade, because an upgrade must never change servers.json.
|
|
2255
|
+
const deadline = Date.now() + Math.min(8000, Math.max(0, message.timeoutMs ?? 8000));
|
|
2256
|
+
while (draining() &&
|
|
2257
|
+
Date.now() < deadline) {
|
|
2258
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 25));
|
|
2259
|
+
}
|
|
2260
|
+
const state = upgradeState();
|
|
2261
|
+
if (state.unsafe ||
|
|
2262
|
+
state.configBusy) {
|
|
2263
|
+
throw new Error('Unfinished config work blocks upgrade. Let it finish and retry; nothing was replayed.');
|
|
2264
|
+
}
|
|
2265
|
+
} else if (message.action === 'activate' ||
|
|
2266
|
+
message.action === 'resume') {
|
|
2267
|
+
if (upgradeState().unsafe) throw new Error('Unresolved config transaction prevents admission.');
|
|
2268
|
+
if (message.action === 'activate') {
|
|
2269
|
+
// Session records may change while the old version runs; only the config must match.
|
|
2270
|
+
if (!isDeepStrictEqual(new PoolingFiles(CONFIG).inspect(CONFIG), message.expectedAuthority)) {
|
|
2271
|
+
throw new Error('Configuration changed before admission; the bridge remains held.');
|
|
2272
|
+
}
|
|
2273
|
+
} else {
|
|
2274
|
+
await poolingConfig.reloadSaved('upgrade resumed');
|
|
2275
|
+
}
|
|
2276
|
+
upgradeHeld = false;
|
|
2277
|
+
for (const name of Object.keys(servers)) refillPool(name);
|
|
2278
|
+
} else if (message.action !== 'inspect') {
|
|
2279
|
+
throw new Error('Unknown upgrade action.');
|
|
2280
|
+
}
|
|
2281
|
+
const authority = new PoolingFiles(CONFIG).inspect(CONFIG);
|
|
2282
|
+
reply({ ok: true, ...upgradeState(), authority });
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
reply({ ok: false, error: error.message, ...upgradeState() });
|
|
2285
|
+
} finally {
|
|
2286
|
+
upgradeControlBusy = false;
|
|
2287
|
+
}
|
|
2142
2288
|
});
|
|
2143
2289
|
|
|
2144
2290
|
// Ending the streams before exiting matters: a client that sees its SSE stream close cleanly
|
package/bin/pooling-batches.mjs
CHANGED
|
@@ -47,6 +47,19 @@ export class PoolingBatches {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
upgradeState() {
|
|
51
|
+
const writer = this.#writer.upgradeState();
|
|
52
|
+
return {
|
|
53
|
+
busy: this.#queued > 0 ||
|
|
54
|
+
Boolean(this.#applying) ||
|
|
55
|
+
Boolean(this.#watchReload) ||
|
|
56
|
+
writer.pending > 0,
|
|
57
|
+
unsafe: writer.failed ||
|
|
58
|
+
Boolean(this.#pending) ||
|
|
59
|
+
[...this.#records.values()].some(record => !['applied', 'cancelled'].includes(record.status)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
stage(request, options = {}) {
|
|
51
64
|
if (this.#closed) return Promise.reject(this.#closedError());
|
|
52
65
|
if (this.#queued >= 16) {
|
package/bin/pooling-writer.mjs
CHANGED
|
@@ -65,6 +65,10 @@ export class PoolingConfigWriter {
|
|
|
65
65
|
this.#configPath = configPath;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
upgradeState() {
|
|
69
|
+
return { pending: this.#pending.size, failed: Boolean(this.#failure) };
|
|
70
|
+
}
|
|
71
|
+
|
|
68
72
|
apply(request, options = {}) {
|
|
69
73
|
return this.#send('apply', request, options);
|
|
70
74
|
}
|
package/bin/prewarm.mjs
CHANGED
|
@@ -1,32 +1,45 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { request } from 'node:http';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
4
|
+
import { text as readText } from 'node:stream/consumers';
|
|
3
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { canonicalPort } from './cli-selection.mjs';
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
// node:http, not fetch: on Windows, Node 23, 24 before 24.20.0, 25, and 26 before 26.7.0 can abort
|
|
11
|
+
// with a libuv assertion when the CLI exits soon after fetch (nodejs/node#56645).
|
|
12
|
+
async function sendRequest(port, path, { method = 'GET', headers = {}, body } = {}) {
|
|
13
|
+
const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
14
|
+
try {
|
|
15
|
+
const response = await new Promise((resolve, reject) => {
|
|
16
|
+
const outgoing = request({ hostname: '127.0.0.1', port, path, method, headers, signal }, resolve);
|
|
17
|
+
outgoing.on('error', reject);
|
|
18
|
+
outgoing.end(body);
|
|
19
|
+
});
|
|
20
|
+
return { status: response.statusCode, text: await readText(response) };
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (signal.aborted) {
|
|
23
|
+
const advice = method === 'GET' ? '' : ' The change may still apply; reread settings before retrying.';
|
|
24
|
+
throw new Error(`Bridge on :${port} did not answer within ${REQUEST_TIMEOUT_MS / 1000}s.${advice}`);
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
13
28
|
}
|
|
14
29
|
|
|
15
30
|
async function jsonRequest(port, path, options = {}) {
|
|
16
|
-
const response = await
|
|
17
|
-
...options,
|
|
18
|
-
signal: AbortSignal.timeout(10_000),
|
|
19
|
-
});
|
|
31
|
+
const response = await sendRequest(port, path, options);
|
|
20
32
|
if (response.status === 401) {
|
|
21
33
|
throw new Error('Admin nonce was rejected. Check --config points to the running bridge, then retry.');
|
|
22
34
|
}
|
|
23
35
|
let result;
|
|
24
36
|
try {
|
|
25
|
-
result =
|
|
37
|
+
result = JSON.parse(response.text);
|
|
26
38
|
} catch {
|
|
27
39
|
throw new Error(`Bridge on :${port} returned a non-JSON response (${response.status})`);
|
|
28
40
|
}
|
|
29
|
-
|
|
41
|
+
const succeeded = response.status >= 200 && response.status <= 299;
|
|
42
|
+
if (!succeeded) {
|
|
30
43
|
throw new Error(result.error || `Bridge on :${port} rejected the request (${response.status})`);
|
|
31
44
|
}
|
|
32
45
|
return { status: response.status, body: result };
|
|
@@ -118,25 +131,45 @@ function shellArgument(text) {
|
|
|
118
131
|
return `'${text.replaceAll("'", "'\\''")}'`;
|
|
119
132
|
}
|
|
120
133
|
|
|
121
|
-
|
|
134
|
+
function windowsArgument(value) {
|
|
135
|
+
let result = '"';
|
|
136
|
+
let slashes = 0;
|
|
137
|
+
for (const character of value) {
|
|
138
|
+
if (character === '\\') {
|
|
139
|
+
slashes++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
result += '\\'.repeat(character === '"' ? slashes * 2 + 1 : slashes) + character;
|
|
143
|
+
slashes = 0;
|
|
144
|
+
}
|
|
145
|
+
return result + '\\'.repeat(slashes * 2) + '"';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatUndoCommand(port, name, undoId, config, instance) {
|
|
149
|
+
port = canonicalPort(port);
|
|
122
150
|
const executable = process.execPath;
|
|
123
151
|
const entry = fileURLToPath(new URL('./cli.mjs', import.meta.url));
|
|
124
|
-
const values = [executable, entry, name, undoId, ...(config ? [config] : [])];
|
|
152
|
+
const values = [executable, entry, name, undoId, ...(config ? [config] : []), ...(instance ? [instance] : [])];
|
|
125
153
|
const needsExplicitShell = process.platform === 'win32' &&
|
|
126
|
-
values.some((value) => /[%!$`"^\r\n]|\\$/.test(value));
|
|
154
|
+
(Boolean(instance) || values.some((value) => /[%!$`"^\r\n]|\\$/.test(value)));
|
|
127
155
|
if (needsExplicitShell) {
|
|
128
156
|
const quote = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
129
|
-
const
|
|
130
|
-
|
|
157
|
+
const argumentsList = [entry, ...(instance ? ['--instance', instance] : []),
|
|
158
|
+
'prewarm', '--port', String(port), ...(config ? ['--config', config] : []), '--server', name, '--undo', undoId];
|
|
159
|
+
const commandLine = argumentsList.map(windowsArgument).join(' ');
|
|
160
|
+
const command = `$info = New-Object System.Diagnostics.ProcessStartInfo; $info.FileName = ${quote(executable)}; ` +
|
|
161
|
+
`$info.UseShellExecute = $false; $info.Arguments = ${quote(commandLine)}; ` +
|
|
162
|
+
'$child = [System.Diagnostics.Process]::Start($info); $child.WaitForExit(); exit $child.ExitCode';
|
|
131
163
|
return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(command, 'utf16le').toString('base64')}`;
|
|
132
164
|
}
|
|
133
165
|
const configArg = config ? ` --config ${shellArgument(config)}` : '';
|
|
166
|
+
const instanceArg = instance ? ` --instance ${shellArgument(instance)}` : '';
|
|
134
167
|
const node = process.platform === 'win32' ? 'node.exe' : shellArgument(executable);
|
|
135
|
-
return `${node} ${shellArgument(entry)} prewarm --port ${port}${configArg} --server ${shellArgument(name)} --undo ${undoId}`;
|
|
168
|
+
return `${node} ${shellArgument(entry)}${instanceArg} prewarm --port ${port}${configArg} --server ${shellArgument(name)} --undo ${instance ? shellArgument(undoId) : undoId}`;
|
|
136
169
|
}
|
|
137
170
|
|
|
138
|
-
export async function runPrewarm(options, { configPath, ports, write = console.log } = {}) {
|
|
139
|
-
const selectedPorts = options.port ? [
|
|
171
|
+
export async function runPrewarm(options, { configPath, ports, instance, write = console.log } = {}) {
|
|
172
|
+
const selectedPorts = options.port === undefined ? ports.map(canonicalPort) : [canonicalPort(options.port)];
|
|
140
173
|
const actionCount = [options.enable, options.disable, options.undo].filter(Boolean).length;
|
|
141
174
|
if (actionCount > 1) throw new Error('Choose only one of --enable, --disable or --undo');
|
|
142
175
|
if (actionCount &&
|
|
@@ -156,6 +189,7 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
|
|
|
156
189
|
for (const port of selectedPorts) {
|
|
157
190
|
const { body: snapshot } = await jsonRequest(port, '/api/status');
|
|
158
191
|
if (snapshot.service !== 'mcp-pacemaker') throw new Error(`:${port} is not a pacemaker bridge`);
|
|
192
|
+
if (snapshot.port !== port) throw new Error('Bridge reported a different port; no admin nonce was sent.');
|
|
159
193
|
snapshots.push(snapshot);
|
|
160
194
|
}
|
|
161
195
|
|
|
@@ -194,7 +228,7 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
|
|
|
194
228
|
typeof snapshot.prewarm.saveWarning === 'string') {
|
|
195
229
|
write(`File save notice: ${snapshot.prewarm.saveWarning}`);
|
|
196
230
|
}
|
|
197
|
-
const response = await jsonRequest(
|
|
231
|
+
const response = await jsonRequest(selectedPorts[0], `/admin/servers/${encodeURIComponent(name)}/pooling`, {
|
|
198
232
|
method: 'POST',
|
|
199
233
|
headers: { 'content-type': 'application/json', 'x-mcp-nonce': nonce, 'x-mcp-pooling-batch': '1' },
|
|
200
234
|
body: JSON.stringify(body),
|
|
@@ -220,14 +254,14 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
|
|
|
220
254
|
else action = 'pre-warming disable staged';
|
|
221
255
|
write(`${name}: ${action}. Pending reload${timing}; not active yet.`);
|
|
222
256
|
if (result.undoId) {
|
|
223
|
-
write(`Cancel while pending / undo after apply (entire batch: ${scope}): ${formatUndoCommand(
|
|
257
|
+
write(`Cancel while pending / undo after apply (entire batch: ${scope}): ${formatUndoCommand(selectedPorts[0], name, result.undoId, options.config, instance)}`);
|
|
224
258
|
}
|
|
225
259
|
} else if (result.cancelled) {
|
|
226
260
|
write(`${name}: pending batch cancelled. Active settings were not changed.`);
|
|
227
261
|
} else {
|
|
228
262
|
write(`${name}: ${options.undo ? 'restored previous pooling settings' : body.mode === 'pool' ? `enabled ${body.minWarm} warm slot(s)` : 'disabled pre-warming'}`);
|
|
229
263
|
if (result.undoId) {
|
|
230
|
-
write(`Undo: ${formatUndoCommand(
|
|
264
|
+
write(`Undo: ${formatUndoCommand(selectedPorts[0], name, result.undoId, options.config, instance)}`);
|
|
231
265
|
}
|
|
232
266
|
}
|
|
233
267
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
import { requireOrdinaryUpgradeCaller } from './windows-task-channel.mjs';
|
|
3
|
+
|
|
4
|
+
export class UserRuntimeContext {
|
|
5
|
+
constructor({ host = process, linuxStatus, windowsCheck = requireOrdinaryUpgradeCaller } = {}) {
|
|
6
|
+
this.host = host;
|
|
7
|
+
this.linuxStatus = linuxStatus ?? (() => this.readLinuxStatus());
|
|
8
|
+
this.windowsCheck = windowsCheck;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
refuse(reason, code = 'USER_RUNTIME_CONTEXT_UNVERIFIED') {
|
|
12
|
+
throw Object.assign(new Error(`User-runtime execution refused: ${reason}. No identity or privilege change was attempted.`),
|
|
13
|
+
{ code });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
identity() {
|
|
17
|
+
const value = {};
|
|
18
|
+
for (const [name, method] of [['uid', 'getuid'], ['euid', 'geteuid'], ['gid', 'getgid'], ['egid', 'getegid']]) {
|
|
19
|
+
if (typeof this.host[method] !== 'function') this.refuse('effective identity is unavailable');
|
|
20
|
+
value[name] = this.host[method]();
|
|
21
|
+
if (!Number.isSafeInteger(value[name]) ||
|
|
22
|
+
value[name] < 0 ||
|
|
23
|
+
value[name] > 0xffffffff) this.refuse('effective identity is invalid');
|
|
24
|
+
}
|
|
25
|
+
if (value.uid === 0 ||
|
|
26
|
+
value.euid === 0) this.refuse("run this operation from the installation owner's non-root session", 'USER_RUNTIME_PRIVILEGED');
|
|
27
|
+
if (value.uid !== value.euid ||
|
|
28
|
+
value.gid !== value.egid) this.refuse('real and effective identities differ', 'USER_RUNTIME_PRIVILEGED');
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
readLinuxStatus() {
|
|
33
|
+
const buffer = Buffer.alloc(16385);
|
|
34
|
+
const file = openSync('/proc/self/status', 'r');
|
|
35
|
+
try {
|
|
36
|
+
let length = 0;
|
|
37
|
+
while (length < buffer.length) {
|
|
38
|
+
const read = readSync(file, buffer, length, buffer.length - length, null);
|
|
39
|
+
if (read === 0) break;
|
|
40
|
+
length += read;
|
|
41
|
+
}
|
|
42
|
+
if (length > 16384) this.refuse('Linux process status exceeds its bound');
|
|
43
|
+
return buffer.toString('utf8', 0, length);
|
|
44
|
+
} finally { closeSync(file); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
capabilities(status, identity) {
|
|
48
|
+
if (typeof status !== 'string' ||
|
|
49
|
+
Buffer.byteLength(status) > 16384) this.refuse('Linux process status is invalid');
|
|
50
|
+
const lines = status.split('\n');
|
|
51
|
+
for (const [name, expected] of [['Uid', identity.uid], ['Gid', identity.gid]]) {
|
|
52
|
+
const selected = lines.filter(line => line.startsWith(`${name}:`));
|
|
53
|
+
if (selected.length !== 1) this.refuse(`Linux ${name} observation is unavailable`);
|
|
54
|
+
const fields = selected[0].slice(name.length + 1).trim().split(/\s+/);
|
|
55
|
+
if (fields.length !== 4 ||
|
|
56
|
+
fields.some(field => !/^(0|[1-9][0-9]*)$/.test(field) || Number(field) !== expected)) {
|
|
57
|
+
this.refuse(`Linux real, effective, saved or filesystem ${name} differs`, 'USER_RUNTIME_PRIVILEGED');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const capabilities = {};
|
|
61
|
+
for (const name of ['CapInh', 'CapPrm', 'CapEff', 'CapAmb']) {
|
|
62
|
+
const selected = lines.filter(line => line.startsWith(`${name}:`));
|
|
63
|
+
if (selected.length !== 1) this.refuse(`Linux ${name} observation is unavailable`);
|
|
64
|
+
const value = selected[0].slice(name.length + 1).trim();
|
|
65
|
+
if (!/^[0-9a-fA-F]{16}$/.test(value)) this.refuse(`Linux ${name} observation is invalid`);
|
|
66
|
+
if (BigInt(`0x${value}`) !== 0n) this.refuse(`Linux ${name} contains privileges`, 'USER_RUNTIME_PRIVILEGED');
|
|
67
|
+
capabilities[name] = value.toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
return capabilities;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async require() {
|
|
73
|
+
if (this.host.platform === 'win32') {
|
|
74
|
+
const proof = await this.windowsCheck();
|
|
75
|
+
if (proof?.ordinaryEligible !== true ||
|
|
76
|
+
proof.proofScope !== 'cli-effective-context-snapshot') this.refuse('Windows caller proof is unavailable');
|
|
77
|
+
return { platform: 'win32', proofScope: 'user-runtime-context-snapshot', ordinary: true,
|
|
78
|
+
native: proof, atomicFutureProtection: false };
|
|
79
|
+
}
|
|
80
|
+
if (!['linux', 'darwin'].includes(this.host.platform)) this.refuse('this platform has no supported identity observer');
|
|
81
|
+
const before = this.identity();
|
|
82
|
+
let capabilities;
|
|
83
|
+
if (this.host.platform === 'linux') {
|
|
84
|
+
try { capabilities = this.capabilities(this.linuxStatus(), before); }
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code?.startsWith('USER_RUNTIME_')) throw error;
|
|
87
|
+
throw Object.assign(new Error('User-runtime execution refused: Linux process status could not be read.', { cause: error }),
|
|
88
|
+
{ code: 'USER_RUNTIME_CONTEXT_UNVERIFIED' });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const after = this.identity();
|
|
92
|
+
if (Object.keys(before).some(name => before[name] !== after[name])) this.refuse('effective identity changed during observation');
|
|
93
|
+
return { platform: this.host.platform, proofScope: 'user-runtime-context-snapshot', ordinary: true,
|
|
94
|
+
identity: after, ...(capabilities ? { capabilities } : {}), atomicFutureProtection: false };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function requireUserRuntimeContext() {
|
|
99
|
+
return new UserRuntimeContext().require();
|
|
100
|
+
}
|
package/bin/service-control.mjs
CHANGED
|
@@ -21,6 +21,7 @@ export function servicePaths(config, port) {
|
|
|
21
21
|
return {
|
|
22
22
|
record: join(directory, `service-${port}.json`),
|
|
23
23
|
hold: join(directory, `service-${port}.stopped`),
|
|
24
|
+
admissionHold: join(directory, `service-${port}.admission-held`),
|
|
24
25
|
socket,
|
|
25
26
|
};
|
|
26
27
|
}
|
|
@@ -90,7 +91,7 @@ export function requestService(socket, request) {
|
|
|
90
91
|
});
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
export async function stopManagedService({ root, config, port, request = requestService }) {
|
|
94
|
+
export async function stopManagedService({ root, config, port, quiescedInstanceId, request = requestService }) {
|
|
94
95
|
const paths = servicePaths(config, port);
|
|
95
96
|
let record;
|
|
96
97
|
try {
|
|
@@ -99,6 +100,7 @@ export async function stopManagedService({ root, config, port, request = request
|
|
|
99
100
|
if (error.code !== 'ENOENT') throw error;
|
|
100
101
|
throw new Error(`No managed supervisor record for :${port}. Older or direct bridges require verified OS-service shutdown and disabled autostart; no process was stopped.`);
|
|
101
102
|
}
|
|
103
|
+
|
|
102
104
|
validateServiceRecord(record, { root, config, port });
|
|
103
105
|
let receipt;
|
|
104
106
|
try {
|
|
@@ -110,15 +112,41 @@ export async function stopManagedService({ root, config, port, request = request
|
|
|
110
112
|
receipt.id === record.id &&
|
|
111
113
|
receipt.root === record.root &&
|
|
112
114
|
receipt.config === record.config &&
|
|
113
|
-
receipt.port === record.port
|
|
115
|
+
receipt.port === record.port &&
|
|
116
|
+
(quiescedInstanceId === undefined || receipt.quiescedInstanceId === quiescedInstanceId);
|
|
114
117
|
if (completed) {
|
|
115
118
|
return { root: record.root, config: record.config, port, supervisorId: record.id, alreadyStopped: true };
|
|
116
119
|
}
|
|
117
|
-
const response = await request(paths.socket, { action: 'stop', id: record.id, token: record.token
|
|
120
|
+
const response = await request(paths.socket, { action: 'stop', id: record.id, token: record.token,
|
|
121
|
+
...(quiescedInstanceId ? { quiescedInstanceId } : {}) });
|
|
118
122
|
if (response?.id !== record.id ||
|
|
119
123
|
response.stopped !== true ||
|
|
120
|
-
response.autostartHeld !== true
|
|
124
|
+
response.autostartHeld !== true ||
|
|
125
|
+
(quiescedInstanceId !== undefined && response.quiescedInstanceId !== quiescedInstanceId)) {
|
|
121
126
|
throw new Error('Supervisor did not verify shutdown and its autostart hold. Do not replace the installation root.');
|
|
122
127
|
}
|
|
123
128
|
return { root: record.root, config: record.config, port, supervisorId: record.id };
|
|
124
129
|
}
|
|
130
|
+
|
|
131
|
+
export async function controlManagedService({ root, config, port }, action, expected = {}) {
|
|
132
|
+
const record = validateServiceRecord(
|
|
133
|
+
JSON.parse(readFileSync(servicePaths(config, port).record, 'utf8')), { root, config, port });
|
|
134
|
+
if (record.upgradeProtocol !== 1) {
|
|
135
|
+
throw new Error('This supervisor has no managed admission protocol. Legacy migration requires exact OS-service inspection; no process was stopped.');
|
|
136
|
+
}
|
|
137
|
+
const response = await requestService(record.socket, {
|
|
138
|
+
id: record.id, token: record.token, action,
|
|
139
|
+
expectedAuthority: expected.authority, expectedSessionState: expected.sessionState,
|
|
140
|
+
});
|
|
141
|
+
const identified = response.id === record.id &&
|
|
142
|
+
response.instanceId === record.instanceId &&
|
|
143
|
+
response.version === record.version &&
|
|
144
|
+
response.port === port;
|
|
145
|
+
if (!identified ||
|
|
146
|
+
response.ok !== true) {
|
|
147
|
+
// Only a refusal from the identified bridge is definite; anything else leaves the outcome unknown.
|
|
148
|
+
throw Object.assign(new Error(response.error || 'Managed bridge identity/admission could not be verified.'),
|
|
149
|
+
{ refused: identified && response.ok === false });
|
|
150
|
+
}
|
|
151
|
+
return response;
|
|
152
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fork } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { durableJson, loadInstance, readManagedJson } from './managed-state.mjs';
|
|
7
|
+
|
|
8
|
+
const directory = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const instance = loadInstance(directory);
|
|
10
|
+
let boundPort = instance.port;
|
|
11
|
+
const journalPath = join(directory, 'journal.json');
|
|
12
|
+
if (existsSync(journalPath)) {
|
|
13
|
+
const journal = readManagedJson(journalPath);
|
|
14
|
+
const legacyStart = journal.phase === 'legacy-launching' &&
|
|
15
|
+
journal.legacy?.protocol === 1 &&
|
|
16
|
+
journal.legacy.acknowledged === 'restart-with-uncertain-http-and-partial-tree-v1';
|
|
17
|
+
const authorizedStart = (['launching', 'rollback-launching'].includes(journal.phase) || legacyStart) &&
|
|
18
|
+
journal.launchToken === process.env.MCP_PACEMAKER_UPGRADE_TOKEN &&
|
|
19
|
+
process.env.MCP_PACEMAKER_UPGRADE_PENDING === '1';
|
|
20
|
+
if (!['committed', 'rolled-back', 'aborted'].includes(journal.phase) &&
|
|
21
|
+
!authorizedStart) {
|
|
22
|
+
throw new Error('Upgrade recovery required; stable autostart remains held. Run upgrade --recover --instance <directory>.');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (existsSync(join(directory, 'stopped'))) {
|
|
26
|
+
console.log('Managed instance is held stopped. Use an explicit start.');
|
|
27
|
+
} else {
|
|
28
|
+
const child = fork(join(instance.active.root, 'supervisor', 'supervise.mjs'), [
|
|
29
|
+
'--port', String(instance.port), '--config', instance.config, '--cwd', instance.cwd,
|
|
30
|
+
], {
|
|
31
|
+
execPath: instance.node, execArgv: [], windowsHide: true,
|
|
32
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
|
33
|
+
env: { ...process.env, MCP_PACEMAKER_MANAGED_INSTANCE: directory },
|
|
34
|
+
});
|
|
35
|
+
child.on('message', (message) => {
|
|
36
|
+
if (message?.type === 'supervisor-ready' &&
|
|
37
|
+
boundPort === 0) {
|
|
38
|
+
const current = readManagedJson(join(directory, 'instance.json'));
|
|
39
|
+
if (current.id !== instance.id ||
|
|
40
|
+
current.port !== 0) throw new Error('Managed port identity changed during allocation.');
|
|
41
|
+
durableJson(join(directory, 'instance.json'), { ...current, port: message.port });
|
|
42
|
+
boundPort = message.port;
|
|
43
|
+
}
|
|
44
|
+
if (message?.type === 'supervisor-ready' &&
|
|
45
|
+
message.port !== boundPort) throw new Error('Managed restart changed the persisted listener port.');
|
|
46
|
+
if (process.connected) process.send(message);
|
|
47
|
+
});
|
|
48
|
+
process.on('message', (message) => { if (child.connected) child.send(message); });
|
|
49
|
+
const shutdown = () => { if (child.connected) child.send('stop'); };
|
|
50
|
+
process.on('SIGTERM', shutdown);
|
|
51
|
+
process.on('SIGINT', shutdown);
|
|
52
|
+
child.on('error', (error) => { console.error(error.message); process.exitCode = 1; });
|
|
53
|
+
child.on('exit', (code) => { process.exitCode = code ?? 1; if (process.connected) process.disconnect(); });
|
|
54
|
+
}
|