mcp-pacemaker 2.0.2 → 2.1.0
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 +75 -1
- package/CONTRIBUTING.md +39 -13
- package/README.md +255 -8
- 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-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 +134 -0
- package/bin/account-worker-session.mjs +215 -0
- package/bin/cli-dispatch.mjs +154 -0
- package/bin/cli-main.mjs +1007 -0
- package/bin/cli-selection.mjs +43 -0
- package/bin/cli.mjs +7 -876
- package/bin/legacy-1.3.json +32 -0
- package/bin/legacy-installation.mjs +217 -0
- package/bin/legacy-task.mjs +52 -0
- package/bin/legacy-upgrade.mjs +471 -0
- package/bin/managed-runtime.mjs +170 -0
- package/bin/managed-state.mjs +257 -0
- package/bin/managed-upgrade.mjs +491 -0
- package/bin/mcp-bridge.mjs +132 -4
- package/bin/pooling-batches.mjs +13 -0
- package/bin/pooling-writer.mjs +4 -0
- package/bin/prewarm.mjs +33 -21
- package/bin/runtime-context.mjs +100 -0
- package/bin/service-control.mjs +27 -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 +8 -0
- package/bin/upgrade-dependencies.mjs +122 -0
- package/bin/upgrade-package.mjs +203 -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 +783 -0
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +841 -0
- package/bin/windows-legacy-process.mjs +241 -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 +205 -0
- package/docs/execution-context.md +113 -0
- package/package.json +12 -5
- package/supervisor/supervise.mjs +226 -40
- package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
- package/tools/windows-legacy/README.md +225 -0
- package/tools/windows-legacy/build.ps1 +122 -0
- package/tools/windows-legacy/inventory.mjs +90 -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
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import {
|
|
7
|
+
acquireInstanceLock, createInstance, directoryIdentity, durableJson,
|
|
8
|
+
loadInstance, readInstanceState, readRecoveryContext, readManagedJson, verifySelection,
|
|
9
|
+
} from './managed-state.mjs';
|
|
10
|
+
import { acquireUpgrade, configuredRegistry, exactVersion, unpackUpgrade, verifyArchive } from './upgrade-package.mjs';
|
|
11
|
+
import { inspectInstance, listenerOpen, startInstance, stopInstance } from './managed-runtime.mjs';
|
|
12
|
+
import { PoolingFiles, hasPoolingTransaction } from './pooling-files.mjs';
|
|
13
|
+
import { inspectAutostart } from './upgrade-autostart.mjs';
|
|
14
|
+
import { validateRegistry } from './update-check.mjs';
|
|
15
|
+
import { servicePaths } from './service-control.mjs';
|
|
16
|
+
import { stageCliDependencies } from './upgrade-dependencies.mjs';
|
|
17
|
+
import { LegacyUpgrader, legacyDormant } from './legacy-upgrade.mjs';
|
|
18
|
+
import { requireUserRuntimeContext } from './runtime-context.mjs';
|
|
19
|
+
import { LEGACY_WARNING, prepareLegacyPlan } from './legacy-installation.mjs';
|
|
20
|
+
|
|
21
|
+
const terminal = ['committed', 'rolled-back', 'aborted'];
|
|
22
|
+
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
23
|
+
|
|
24
|
+
function sessionState(config) {
|
|
25
|
+
const path = join(dirname(config), 'sessions.json');
|
|
26
|
+
return existsSync(path) ? new PoolingFiles(config).inspect(path) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function verifyRollbackState(instance, journal) {
|
|
30
|
+
new PoolingFiles(instance.config).verify(instance.config, journal.authority);
|
|
31
|
+
if (!same(sessionState(instance.config), journal.sessionState)) throw new Error('Persisted session state changed; rollback/activation refused.');
|
|
32
|
+
if (hasPoolingTransaction(instance.config)) throw new Error('Config outcome is uncertain; automatic rollback refused.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function instanceBinding(instance) {
|
|
36
|
+
return { config: instance.config, cwd: instance.cwd, node: instance.node, port: instance.port };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateJournal(instance, journal) {
|
|
40
|
+
const binding = instanceBinding(instance);
|
|
41
|
+
if (journal.sxs &&
|
|
42
|
+
journal.binding?.port === 0) binding.port = 0;
|
|
43
|
+
const matches = journal.protocol === 1 &&
|
|
44
|
+
journal.instanceId === instance.id &&
|
|
45
|
+
same(binding, journal.binding) &&
|
|
46
|
+
[journal.previous?.root, journal.target?.root].includes(instance.active.root);
|
|
47
|
+
if (!matches) throw new Error('Journal configuration or active selection does not match this managed instance.');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertNoPendingJournal(instance) {
|
|
51
|
+
const path = join(instance.directory, 'journal.json');
|
|
52
|
+
if (!existsSync(path)) return;
|
|
53
|
+
const journal = readManagedJson(path);
|
|
54
|
+
validateJournal(instance, journal);
|
|
55
|
+
if (!terminal.includes(journal.phase)) throw new Error('An unfinished upgrade requires --recover --instance <directory>.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function upgradeOptions(options) {
|
|
59
|
+
if (options.self &&
|
|
60
|
+
(options.to || options.sxs || options.plan || options.recover || options.instance || options.yes)) {
|
|
61
|
+
throw new Error('--self is guidance only; do not combine it with managed upgrade options.');
|
|
62
|
+
}
|
|
63
|
+
if (options.port !== undefined &&
|
|
64
|
+
!options.sxs) throw new Error('--port is only valid with --sxs. Normal upgrades preserve the selected instance port.');
|
|
65
|
+
if (options.recover) {
|
|
66
|
+
if (!options.instance ||
|
|
67
|
+
options.to ||
|
|
68
|
+
options.sxs ||
|
|
69
|
+
options.plan ||
|
|
70
|
+
options.registry !== undefined) throw new Error('--recover requires --instance and cannot select a new target or registry.');
|
|
71
|
+
return { ...options };
|
|
72
|
+
}
|
|
73
|
+
exactVersion(options.to);
|
|
74
|
+
let port = 0;
|
|
75
|
+
if (options.port !== undefined) {
|
|
76
|
+
if (!/^[1-9][0-9]*$/.test(String(options.port))) throw new Error('--sxs --port must be an integer from 1 to 65535.');
|
|
77
|
+
port = Number(options.port);
|
|
78
|
+
if (port > 65535) throw new Error('--sxs --port must be an integer from 1 to 65535.');
|
|
79
|
+
}
|
|
80
|
+
return { ...options, ...(options.sxs ? { port } : {}), registry: validateRegistry(options.registry) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function discoverInstances(home) {
|
|
84
|
+
const parent = join(home, 'managed');
|
|
85
|
+
if (!existsSync(parent)) return [];
|
|
86
|
+
return readdirSync(parent, { withFileTypes: true })
|
|
87
|
+
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
|
|
88
|
+
.map(entry => readInstanceState(join(parent, entry.name)))
|
|
89
|
+
.filter(instance => !legacyDormant(instance))
|
|
90
|
+
.map(instance => loadInstance(instance.directory));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function planUpgrade(options, { home, legacy, autostart: inspectStart = inspectAutostart } = {}) {
|
|
94
|
+
const selected = upgradeOptions(options);
|
|
95
|
+
const instances = selected.instance ? [loadInstance(resolve(selected.instance))] : discoverInstances(home);
|
|
96
|
+
if (!instances.length &&
|
|
97
|
+
!selected.instance) {
|
|
98
|
+
const prepared = await prepareLegacyPlan(selected, { home, ...legacy });
|
|
99
|
+
return {
|
|
100
|
+
protocol: 1, options: selected, instance: prepared.instance, legacy: prepared.legacy,
|
|
101
|
+
legacyRuntime: { task: prepared.task, processes: prepared.processes },
|
|
102
|
+
from: '1.3.0', to: selected.to, targetMajor: Number(selected.to.split('.')[0]),
|
|
103
|
+
majorChange: selected.to.split('.')[0] !== '1',
|
|
104
|
+
registry: await configuredRegistry(selected.registry), port: prepared.instance.port,
|
|
105
|
+
affectedInstances: [prepared.legacy.backendInstanceId],
|
|
106
|
+
autostart: { kind: 'legacy', name: prepared.legacy.registration.name },
|
|
107
|
+
downtime: LEGACY_WARNING,
|
|
108
|
+
rollback: 'Only before target admission, with unchanged config/session authority and verified captured-root/observed-set stop. Uncertain launch, task or HTTP outcomes require explicit recovery.',
|
|
109
|
+
hostWiring: 'Unchanged. The same installed CLI entry will dispatch the selected managed version.',
|
|
110
|
+
legacyAdoption: 'One-time restart; tree completeness stays unproven. Unattributed historical processes are left untouched.',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (instances.length !== 1) {
|
|
114
|
+
throw new Error(instances.length
|
|
115
|
+
? 'Multiple managed instances found. Select exactly one with --instance <directory>; --port is reserved for --sxs.'
|
|
116
|
+
: 'No stable managed installation found. Legacy 1.3 cannot prove idle HTTP work or close admission; automatic legacy migration is blocked. No download, service, autostart or host-wiring change was made.');
|
|
117
|
+
}
|
|
118
|
+
const instance = instances[0];
|
|
119
|
+
if (selected.sxs) servicePaths(join(dirname(instance.config), 'sxs-00000000', 'servers.json'), selected.port || 65535);
|
|
120
|
+
assertNoPendingJournal(instance);
|
|
121
|
+
const autostart = await inspectStart(instance);
|
|
122
|
+
const running = await inspectInstance(instance);
|
|
123
|
+
if (running.unsafe) throw new Error('Unresolved configuration transaction blocks upgrade.');
|
|
124
|
+
if (selected.sxs &&
|
|
125
|
+
selected.port &&
|
|
126
|
+
await listenerOpen(selected.port)) throw new Error('Explicit --sxs port is busy. No listener was adopted or stopped.');
|
|
127
|
+
return {
|
|
128
|
+
protocol: 1, options: selected, instance,
|
|
129
|
+
from: running.version, to: selected.to,
|
|
130
|
+
targetMajor: Number(selected.to.split('.')[0]),
|
|
131
|
+
majorChange: selected.to.split('.')[0] !== running.version.split('.')[0],
|
|
132
|
+
registry: await configuredRegistry(selected.registry),
|
|
133
|
+
port: selected.sxs ? selected.port || 'OS-assigned when the new listener binds' : instance.port,
|
|
134
|
+
affectedInstances: [instance.id], autostart,
|
|
135
|
+
downtime: selected.sxs ? 'Existing service remains running.' : 'Short restart; active clients may need a reload. No zero-downtime handoff.',
|
|
136
|
+
rollback: 'Automatic only before target admission, with unchanged configuration bytes and authority and verified stop. Uncertain outcomes require manual recovery.',
|
|
137
|
+
hostWiring: 'Unchanged. SxS does not rewire any client.',
|
|
138
|
+
legacyAdoption: 'Not performed. Unknown or older supervisors/autostart are refused before interruption.',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function describeUpgrade(plan) {
|
|
143
|
+
return [
|
|
144
|
+
`Managed ${plan.options.sxs ? 'SxS' : 'upgrade'}: ${plan.from} -> ${plan.to} (target major ${plan.targetMajor}${plan.majorChange ? '; explicit major change' : ''})`,
|
|
145
|
+
`Instance: ${plan.instance.id}\nDirectory: ${plan.instance.directory}\nConfig: ${plan.instance.config}`,
|
|
146
|
+
`Port: ${plan.port}\nRegistry: ${plan.registry}`,
|
|
147
|
+
plan.downtime, plan.hostWiring, `Rollback: ${plan.rollback}`,
|
|
148
|
+
`Legacy: ${plan.legacyAdoption}`,
|
|
149
|
+
'Target archive integrity, runtime/engine/capability and packaged startup are checked before interruption.',
|
|
150
|
+
].join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export class ManagedUpgrader {
|
|
154
|
+
constructor({ acquire = acquireUpgrade, dependencies = stageCliDependencies, checkpoint = () => {}, legacy,
|
|
155
|
+
autostart = inspectAutostart, beforeInterruption = async () => {},
|
|
156
|
+
beforeStop = async () => {}, beforeAdmission = async () => {} } = {}) {
|
|
157
|
+
this.acquire = acquire;
|
|
158
|
+
this.dependencies = dependencies;
|
|
159
|
+
this.checkpoint = checkpoint;
|
|
160
|
+
this.legacy = legacy;
|
|
161
|
+
this.autostart = autostart;
|
|
162
|
+
this.beforeInterruption = beforeInterruption;
|
|
163
|
+
this.beforeStop = beforeStop;
|
|
164
|
+
this.beforeAdmission = beforeAdmission;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
transition(directory, journal, phase, changes = {}) {
|
|
168
|
+
validateJournal(readInstanceState(directory), journal);
|
|
169
|
+
Object.assign(journal, changes, { phase });
|
|
170
|
+
durableJson(join(directory, 'journal.json'), journal);
|
|
171
|
+
this.checkpoint(phase, journal);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async stage(plan, operation) {
|
|
175
|
+
const work = join(plan.instance.directory, 'staging', operation);
|
|
176
|
+
mkdirSync(work, { recursive: true, mode: 0o700 });
|
|
177
|
+
const artifact = await this.acquire(plan.to, work, { registry: plan.registry });
|
|
178
|
+
verifyArchive(artifact.bytes, artifact.integrity);
|
|
179
|
+
const root = join(plan.instance.directory, 'versions', `${plan.to}-${operation}`);
|
|
180
|
+
mkdirSync(dirname(root), { recursive: true, mode: 0o700 });
|
|
181
|
+
mkdirSync(root, { mode: 0o700 });
|
|
182
|
+
const nodeVersion = execFileSync(plan.instance.node, ['--version'], {
|
|
183
|
+
encoding: 'utf8', windowsHide: true, timeout: 10000,
|
|
184
|
+
}).trim().replace(/^v/, '');
|
|
185
|
+
const inventory = unpackUpgrade(artifact.bytes, plan.to, root, nodeVersion);
|
|
186
|
+
const dependencyFiles = await this.dependencies(root, { registry: plan.registry, cache: join(work, 'cache') });
|
|
187
|
+
inventory.files.push(...dependencyFiles);
|
|
188
|
+
const selection = { root, version: plan.to, directoryIdentity: directoryIdentity(root), files: inventory.files };
|
|
189
|
+
durableJson(join(work, 'verified-package.json'), selection);
|
|
190
|
+
const preflight = realpathSync(mkdtempSync(join(tmpdir(), 'up-')));
|
|
191
|
+
const preflightIdentity = directoryIdentity(preflight);
|
|
192
|
+
const config = join(preflight, 'preflight.json');
|
|
193
|
+
writeFileSync(config, '{}\n', { flag: 'wx', mode: 0o600 });
|
|
194
|
+
const cliVersion = execFileSync(plan.instance.node, [join(root, 'bin', 'cli-main.mjs'), '--version'], {
|
|
195
|
+
encoding: 'utf8', windowsHide: true, timeout: 15000,
|
|
196
|
+
env: { ...process.env, HOME: preflight, USERPROFILE: preflight, MCP_PACEMAKER_CLI_INSTANCE: '' },
|
|
197
|
+
}).trim();
|
|
198
|
+
if (cliVersion !== plan.to) throw new Error('Packaged target CLI version/startup did not verify.');
|
|
199
|
+
const fixture = createInstance({
|
|
200
|
+
directory: join(preflight, 'instance'), root, config, port: 0, selection, node: plan.instance.node,
|
|
201
|
+
});
|
|
202
|
+
let started;
|
|
203
|
+
let stopped = false;
|
|
204
|
+
try {
|
|
205
|
+
started = await startInstance(fixture.directory, { pending: true });
|
|
206
|
+
await inspectInstance(started.instance);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
stopped = error.startupStopped === true;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
if (started) {
|
|
213
|
+
await stopInstance(started.instance);
|
|
214
|
+
stopped = true;
|
|
215
|
+
}
|
|
216
|
+
if (stopped) {
|
|
217
|
+
if (directoryIdentity(preflight) !== preflightIdentity) throw new Error('Preflight directory identity changed; retained.');
|
|
218
|
+
rmSync(preflight, { recursive: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return selection;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async execute(plan) {
|
|
225
|
+
await requireUserRuntimeContext();
|
|
226
|
+
if (plan.legacy) {
|
|
227
|
+
const upgrader = new LegacyUpgrader({
|
|
228
|
+
...plan.legacyRuntime, ...this.legacy, checkpoint: this.checkpoint, stage: this.stage.bind(this),
|
|
229
|
+
});
|
|
230
|
+
return await upgrader.execute(plan);
|
|
231
|
+
}
|
|
232
|
+
const source = loadInstance(plan.instance.directory);
|
|
233
|
+
const release = acquireInstanceLock(source.directory);
|
|
234
|
+
const operation = randomUUID();
|
|
235
|
+
let journal;
|
|
236
|
+
let destination = source.directory;
|
|
237
|
+
try {
|
|
238
|
+
const locked = loadInstance(source.directory, { verifyFiles: false });
|
|
239
|
+
const matchesPlan = locked.id === source.id &&
|
|
240
|
+
source.id === plan.instance.id &&
|
|
241
|
+
locked.directoryIdentity === source.directoryIdentity &&
|
|
242
|
+
source.directoryIdentity === plan.instance.directoryIdentity &&
|
|
243
|
+
same(locked.active, source.active) &&
|
|
244
|
+
same(source.active, plan.instance.active) &&
|
|
245
|
+
same(instanceBinding(locked), instanceBinding(source)) &&
|
|
246
|
+
same(instanceBinding(source), instanceBinding(plan.instance));
|
|
247
|
+
if (!matchesPlan) throw new Error('Managed selection changed after the plan; create a new plan.');
|
|
248
|
+
assertNoPendingJournal(source);
|
|
249
|
+
await this.autostart(source);
|
|
250
|
+
const current = await inspectInstance(source);
|
|
251
|
+
if (current.unsafe) throw new Error('Unresolved transaction blocks upgrade.');
|
|
252
|
+
const sessions = sessionState(source.config);
|
|
253
|
+
const target = await this.stage(plan, operation);
|
|
254
|
+
verifySelection(target);
|
|
255
|
+
const reloaded = loadInstance(source.directory, { verifyFiles: false });
|
|
256
|
+
if (reloaded.id !== source.id ||
|
|
257
|
+
!same(reloaded.active, source.active)) throw new Error('Instance or active selection changed while staging; nothing was stopped.');
|
|
258
|
+
await this.autostart(source);
|
|
259
|
+
if (plan.options.sxs) {
|
|
260
|
+
return await this.startSxs(source, plan, target, operation);
|
|
261
|
+
}
|
|
262
|
+
journal = {
|
|
263
|
+
protocol: 1, operation, instanceId: source.id, previous: source.active, target,
|
|
264
|
+
binding: instanceBinding(source),
|
|
265
|
+
authority: current.authority, originalInstanceId: current.instanceId,
|
|
266
|
+
sessionState: sessions, launchToken: randomUUID(), phase: 'prepared',
|
|
267
|
+
};
|
|
268
|
+
const backup = join(source.directory, 'staging', operation, 'servers.backup.json');
|
|
269
|
+
new PoolingFiles(source.config).stage(backup, source.config, current.authority, readFileSync(source.config));
|
|
270
|
+
journal.configBackup = backup;
|
|
271
|
+
await this.beforeInterruption(source, journal);
|
|
272
|
+
this.transition(destination, journal, 'prepared');
|
|
273
|
+
this.transition(destination, journal, 'quiescing');
|
|
274
|
+
const drained = await inspectInstance(source, 'quiesce');
|
|
275
|
+
if (!same(drained.authority, journal.authority)) throw new Error('Configuration changed while staging; retry from a new plan.');
|
|
276
|
+
verifyRollbackState(source, journal);
|
|
277
|
+
await this.beforeStop(source, journal);
|
|
278
|
+
this.transition(destination, journal, 'stopping', { quiescedInstanceId: drained.instanceId });
|
|
279
|
+
const stoppedProof = await stopInstance(source, drained.instanceId);
|
|
280
|
+
this.transition(destination, journal, 'stopped', { stoppedProof });
|
|
281
|
+
return await this.activate(destination, journal);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (journal) {
|
|
284
|
+
if (['prepared', 'quiescing'].includes(journal.phase)) {
|
|
285
|
+
this.transition(destination, journal, journal.phase, { error: error.message });
|
|
286
|
+
} else if (['stopped', 'selecting', 'selected', 'launching'].includes(journal.phase)) {
|
|
287
|
+
if (error.cleanupUnverified) throw new Error('Target shutdown is unverified; automatic rollback refused. Preserve the journal and package.', { cause: error });
|
|
288
|
+
await this.rollback(destination, journal, error.message, { startupProof: error.startupProof });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
throw error;
|
|
292
|
+
} finally {
|
|
293
|
+
release();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async startSxs(source, plan, target, operation) {
|
|
298
|
+
const current = await inspectInstance(source);
|
|
299
|
+
if (current.unsafe ||
|
|
300
|
+
current.configBusy) throw new Error('Configuration work is unresolved; SxS config was not copied.');
|
|
301
|
+
if (plan.options.port &&
|
|
302
|
+
await listenerOpen(plan.options.port)) throw new Error('Explicit --sxs port became busy; old instance was not touched.');
|
|
303
|
+
const directory = join(dirname(source.directory), `sxs-${operation}`);
|
|
304
|
+
const profile = join(dirname(source.config), `sxs-${operation.slice(0, 8)}`);
|
|
305
|
+
mkdirSync(profile, { mode: 0o700 });
|
|
306
|
+
const config = join(profile, 'servers.json');
|
|
307
|
+
const files = new PoolingFiles(source.config);
|
|
308
|
+
const authority = files.inspect(source.config);
|
|
309
|
+
files.stage(config, source.config, authority, readFileSync(source.config));
|
|
310
|
+
const sxs = createInstance({
|
|
311
|
+
directory, root: target.root, config, port: plan.options.port,
|
|
312
|
+
cwd: source.cwd, node: source.node,
|
|
313
|
+
});
|
|
314
|
+
const ordered = selection => [...selection.files].sort((left, right) => left.path.localeCompare(right.path));
|
|
315
|
+
if (!same(ordered(sxs.active), ordered(target))) throw new Error('SxS package copy does not match the verified archive.');
|
|
316
|
+
const release = acquireInstanceLock(directory);
|
|
317
|
+
const journal = {
|
|
318
|
+
protocol: 1, operation, instanceId: sxs.id, previous: null, target: sxs.active,
|
|
319
|
+
recovery: source.active,
|
|
320
|
+
binding: instanceBinding(sxs), authority: new PoolingFiles(config).inspect(config),
|
|
321
|
+
sessionState: null, launchToken: randomUUID(), phase: 'starting', sxs: true,
|
|
322
|
+
};
|
|
323
|
+
try {
|
|
324
|
+
this.transition(directory, journal, 'starting');
|
|
325
|
+
this.transition(directory, journal, 'launching');
|
|
326
|
+
const started = await startInstance(directory, { pending: true, token: journal.launchToken });
|
|
327
|
+
verifyRollbackState(started.instance, journal);
|
|
328
|
+
this.transition(directory, journal, 'admitting', {
|
|
329
|
+
targetInstanceId: started.ready.instanceId, binding: instanceBinding(started.instance),
|
|
330
|
+
});
|
|
331
|
+
await inspectInstance(started.instance, 'activate', journal);
|
|
332
|
+
this.transition(directory, journal, 'committed');
|
|
333
|
+
return { status: 'sxs-started', directory, port: started.instance.port, version: target.version };
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (journal.phase === 'launching' &&
|
|
336
|
+
error.startupStopped === true) this.transition(directory, journal, 'aborted', { error: error.message });
|
|
337
|
+
throw new Error(`SxS operation did not complete at ${directory}: ${error.message}`, { cause: error });
|
|
338
|
+
} finally { release(); }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async activate(directory, journal) {
|
|
342
|
+
const instance = loadInstance(directory, { verifyFiles: false });
|
|
343
|
+
verifyRollbackState(instance, journal);
|
|
344
|
+
verifySelection(journal.target);
|
|
345
|
+
this.transition(directory, journal, 'selecting');
|
|
346
|
+
durableJson(join(directory, 'active.json'), journal.target);
|
|
347
|
+
this.transition(directory, journal, 'selected');
|
|
348
|
+
this.transition(directory, journal, 'launching');
|
|
349
|
+
const started = await startInstance(directory, { pending: true, token: journal.launchToken });
|
|
350
|
+
const ready = await inspectInstance(started.instance);
|
|
351
|
+
if (!ready.held ||
|
|
352
|
+
ready.traffic ||
|
|
353
|
+
ready.instanceId === journal.originalInstanceId ||
|
|
354
|
+
!same(ready.authority, journal.authority)) throw new Error('Held target readiness/configuration could not be verified.');
|
|
355
|
+
verifyRollbackState(started.instance, journal);
|
|
356
|
+
await this.beforeAdmission(started.instance, journal);
|
|
357
|
+
// Once this durable intent exists, a lost IPC acknowledgement is never automatic rollback authority.
|
|
358
|
+
this.transition(directory, journal, 'admitting', { targetInstanceId: ready.instanceId });
|
|
359
|
+
await inspectInstance(started.instance, 'activate', journal);
|
|
360
|
+
this.transition(directory, journal, 'committed');
|
|
361
|
+
return { status: 'upgraded', directory, port: started.instance.port, version: journal.target.version };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async verifyRollbackStop(instance, proof) {
|
|
365
|
+
if (!proof?.selection ||
|
|
366
|
+
!['startup', 'managed'].includes(proof.kind) ||
|
|
367
|
+
!proof.receipt ||
|
|
368
|
+
proof.receipt.root !== proof.selection.root ||
|
|
369
|
+
proof.receipt.config !== instance.config ||
|
|
370
|
+
proof.receipt.port !== instance.port) throw new Error('Rollback shutdown proof is incomplete.');
|
|
371
|
+
if (proof.kind === 'startup') {
|
|
372
|
+
const record = readManagedJson(servicePaths(instance.config, instance.port).record);
|
|
373
|
+
if (!proof.receipt.launchToken ||
|
|
374
|
+
record.protocol !== 1 ||
|
|
375
|
+
record.id !== proof.receipt.supervisorId ||
|
|
376
|
+
record.root !== proof.selection.root ||
|
|
377
|
+
record.config !== instance.config ||
|
|
378
|
+
record.port !== instance.port ||
|
|
379
|
+
record.socket !== servicePaths(instance.config, instance.port).socket ||
|
|
380
|
+
record.managedInstance !== instance.directory) throw new Error('Verified startup-stop generation changed.');
|
|
381
|
+
if (await listenerOpen(instance.port)) throw new Error('A listener exists after verified startup shutdown; no rollback launch was attempted.');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const previous = { ...instance, active: proof.selection };
|
|
385
|
+
const stopped = await stopInstance(previous, proof.quiescedInstanceId);
|
|
386
|
+
if (stopped.alreadyStopped !== true ||
|
|
387
|
+
stopped.supervisorId !== proof.receipt.supervisorId) throw new Error('Completed rollback shutdown proof does not match the stopped generation.');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async rollback(directory, journal, error, { startupProof } = {}) {
|
|
391
|
+
const instance = readInstanceState(directory);
|
|
392
|
+
verifyRollbackState(instance, journal);
|
|
393
|
+
verifySelection(journal.previous);
|
|
394
|
+
await this.beforeStop(instance, journal);
|
|
395
|
+
const neverLaunched = ['stopped', 'selecting', 'selected'].includes(journal.phase);
|
|
396
|
+
const rollbackNotLaunched = ['rollback-selecting', 'rollback-selected'].includes(journal.phase);
|
|
397
|
+
let proof;
|
|
398
|
+
if (neverLaunched) {
|
|
399
|
+
proof = { kind: 'managed', selection: journal.previous, receipt: journal.stoppedProof,
|
|
400
|
+
quiescedInstanceId: journal.quiescedInstanceId };
|
|
401
|
+
await this.verifyRollbackStop(instance, proof);
|
|
402
|
+
} else if (rollbackNotLaunched) {
|
|
403
|
+
proof = journal.rollbackStopProof;
|
|
404
|
+
await this.verifyRollbackStop(instance, proof);
|
|
405
|
+
} else if (startupProof) {
|
|
406
|
+
proof = { kind: 'startup', selection: instance.active, receipt: startupProof };
|
|
407
|
+
await this.verifyRollbackStop(instance, proof);
|
|
408
|
+
} else if (await listenerOpen(instance.port)) {
|
|
409
|
+
const state = await inspectInstance(instance);
|
|
410
|
+
if (!state.held ||
|
|
411
|
+
state.traffic) throw new Error('Target may have served traffic; automatic rollback refused.');
|
|
412
|
+
proof = { kind: 'managed', selection: instance.active, receipt: await stopInstance(instance) };
|
|
413
|
+
} else {
|
|
414
|
+
const record = readManagedJson(servicePaths(instance.config, instance.port).record);
|
|
415
|
+
const priorStopId = journal.rollbackStopProof?.receipt?.supervisorId ?? journal.stoppedProof?.supervisorId;
|
|
416
|
+
if (record.id === priorStopId) throw new Error('Launch intent has no new generation proof; a closed port is not proof that launch cannot still occur.');
|
|
417
|
+
proof = { kind: 'managed', selection: instance.active, receipt: await stopInstance(instance) };
|
|
418
|
+
}
|
|
419
|
+
this.transition(directory, journal, 'rollback-selecting', { rollback: true, error, rollbackStopProof: proof });
|
|
420
|
+
durableJson(join(directory, 'active.json'), journal.previous);
|
|
421
|
+
this.checkpoint('rollback-active-written', journal);
|
|
422
|
+
this.transition(directory, journal, 'rollback-selected');
|
|
423
|
+
this.transition(directory, journal, 'rollback-launching', { launchToken: randomUUID() });
|
|
424
|
+
const started = await startInstance(directory, { pending: true, token: journal.launchToken });
|
|
425
|
+
const ready = await inspectInstance(started.instance);
|
|
426
|
+
if (!ready.held ||
|
|
427
|
+
ready.traffic ||
|
|
428
|
+
!same(ready.authority, journal.authority)) throw new Error('Rollback backend is not safely held for admission.');
|
|
429
|
+
verifyRollbackState(started.instance, journal);
|
|
430
|
+
this.checkpoint('rollback-launched', journal);
|
|
431
|
+
await this.beforeAdmission(started.instance, journal);
|
|
432
|
+
this.transition(directory, journal, 'admitting-rollback');
|
|
433
|
+
await inspectInstance(started.instance, 'activate', journal);
|
|
434
|
+
this.transition(directory, journal, 'rolled-back');
|
|
435
|
+
return { status: 'rolled-back', directory, port: started.instance.port, version: journal.previous.version };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async recover(directory) {
|
|
439
|
+
await requireUserRuntimeContext();
|
|
440
|
+
const identity = readInstanceState(resolve(directory));
|
|
441
|
+
// A lock surviving process death is not silently stolen using a reusable PID.
|
|
442
|
+
if (existsSync(join(identity.directory, 'upgrade.lock'))) {
|
|
443
|
+
throw new Error('Upgrade lock remains. Verify the original upgrader has exited and preserve the journal before removing only this instance lock.');
|
|
444
|
+
}
|
|
445
|
+
const release = acquireInstanceLock(identity.directory);
|
|
446
|
+
try {
|
|
447
|
+
const { instance, journal } = readRecoveryContext(identity.directory);
|
|
448
|
+
if (instance.id !== identity.id ||
|
|
449
|
+
instance.directoryIdentity !== identity.directoryIdentity) throw new Error('Recovery instance directory changed while acquiring its lock.');
|
|
450
|
+
validateJournal(instance, journal);
|
|
451
|
+
verifySelection(journal.previous ?? journal.recovery ?? journal.target);
|
|
452
|
+
if (journal.legacy?.protocol === 1 &&
|
|
453
|
+
journal.phase !== 'committed') {
|
|
454
|
+
return await new LegacyUpgrader({ ...this.legacy, checkpoint: this.checkpoint }).recover(instance.directory);
|
|
455
|
+
}
|
|
456
|
+
if (terminal.includes(journal.phase)) return { status: journal.phase, directory: instance.directory, port: instance.port };
|
|
457
|
+
if (journal.sxs) {
|
|
458
|
+
if (!['starting', 'launching'].includes(journal.phase)) throw new Error('SxS admission may have occurred; manual recovery required.');
|
|
459
|
+
if (journal.phase === 'launching') {
|
|
460
|
+
if (!instance.port) throw new Error('SxS port allocation or launch outcome is unknown. Wait for verified readiness or verified shutdown; no abort was recorded.');
|
|
461
|
+
if (!(await listenerOpen(instance.port))) {
|
|
462
|
+
await stopInstance(instance);
|
|
463
|
+
} else {
|
|
464
|
+
const state = await inspectInstance(instance);
|
|
465
|
+
if (!state.held ||
|
|
466
|
+
state.traffic) throw new Error('SxS traffic outcome is uncertain; no stop attempted.');
|
|
467
|
+
await stopInstance(instance);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
this.transition(instance.directory, journal, 'aborted');
|
|
471
|
+
return { status: 'aborted', directory: instance.directory, port: instance.port };
|
|
472
|
+
}
|
|
473
|
+
if (['prepared', 'quiescing'].includes(journal.phase)) {
|
|
474
|
+
await this.beforeStop(instance, journal);
|
|
475
|
+
await inspectInstance(instance, 'resume');
|
|
476
|
+
this.transition(instance.directory, journal, 'aborted');
|
|
477
|
+
return { status: 'aborted', directory: instance.directory, port: instance.port };
|
|
478
|
+
}
|
|
479
|
+
if (journal.phase === 'stopping') {
|
|
480
|
+
await this.beforeStop(instance, journal);
|
|
481
|
+
const stoppedProof = await stopInstance(instance, journal.quiescedInstanceId);
|
|
482
|
+
this.transition(instance.directory, journal, 'stopped', { stoppedProof });
|
|
483
|
+
}
|
|
484
|
+
if (['stopped', 'selecting', 'selected', 'launching',
|
|
485
|
+
'rollback-selecting', 'rollback-selected', 'rollback-launching'].includes(journal.phase)) {
|
|
486
|
+
return await this.rollback(instance.directory, journal, 'Recovered interrupted activation.');
|
|
487
|
+
}
|
|
488
|
+
throw new Error('Admission may have occurred. Automatic recovery/rollback is refused; inspect backend and configuration before a new operation.');
|
|
489
|
+
} finally { release(); }
|
|
490
|
+
}
|
|
491
|
+
}
|