rollbridge 0.1.32 → 0.1.34
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.d/20260830-guardian-retired-replacement-process-key.md +8 -0
- package/package.json +1 -1
- package/src/daemon.js +27 -3
- package/src/guardian-client.js +6 -3
- package/src/process-guardian.js +14 -4
- package/test/guardian-client.test.js +39 -3
- package/test/owner-replacement.test.js +117 -27
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
### Fixed
|
|
2
|
+
|
|
3
|
+
- Carry an exact recovered guardian process key when a ready replacement commits
|
|
4
|
+
after the incumbent control listener has already retired, while retaining the
|
|
5
|
+
replacement transaction, authority, and registered-process fences.
|
|
6
|
+
- Fail closed when an older retained guardian cannot commit that replacement
|
|
7
|
+
atomically after the incumbent control socket disappears, preserving the
|
|
8
|
+
incumbent owner, retained connections, and guardian-managed release processes.
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -353,6 +353,7 @@ export default class RollbridgeDaemon {
|
|
|
353
353
|
let retiredIncumbentControl = false
|
|
354
354
|
let retainIncumbentControl = false
|
|
355
355
|
let incumbentControl = legacyBridge?.incumbentControl
|
|
356
|
+
let committed = /** @type {Promise<Error | undefined> | undefined} */ (undefined)
|
|
356
357
|
|
|
357
358
|
try {
|
|
358
359
|
if (this.config.control.path !== transfer.snapshot.control.path) {
|
|
@@ -401,7 +402,10 @@ export default class RollbridgeDaemon {
|
|
|
401
402
|
finalControlPublished = true
|
|
402
403
|
this.boundControlPath = this.config.control.path
|
|
403
404
|
}
|
|
404
|
-
|
|
405
|
+
committed = this.guardian.waitForEvent("replacement-committed").then(
|
|
406
|
+
() => undefined,
|
|
407
|
+
(error) => error instanceof Error ? error : new Error(String(error))
|
|
408
|
+
)
|
|
405
409
|
const staged = await this.guardian.stageOwnerReplacement(prepared.replacementId, {
|
|
406
410
|
authority: this.ownerAuthority(),
|
|
407
411
|
config: this.config,
|
|
@@ -412,7 +416,18 @@ export default class RollbridgeDaemon {
|
|
|
412
416
|
|
|
413
417
|
if (!staged.committed) {
|
|
414
418
|
if (retiredIncumbentControl) {
|
|
415
|
-
|
|
419
|
+
const processKey = this.guardian.processes.keys().next().value
|
|
420
|
+
|
|
421
|
+
if (!processKey) throw new Error("Retired owner replacement requires an exact recovered guardian process registration")
|
|
422
|
+
try {
|
|
423
|
+
await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId, processKey)
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (!(error instanceof Error) || error.message !== "Guardian commit-retired-owner-replacement requires the committed owner") throw error
|
|
426
|
+
throw new Error(
|
|
427
|
+
"Cannot safely complete atomic owner replacement through the older retained guardian while the incumbent control socket is absent; incumbent owner and connections were preserved",
|
|
428
|
+
{cause: error}
|
|
429
|
+
)
|
|
430
|
+
}
|
|
416
431
|
} else {
|
|
417
432
|
try {
|
|
418
433
|
if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
|
|
@@ -425,7 +440,9 @@ export default class RollbridgeDaemon {
|
|
|
425
440
|
}
|
|
426
441
|
}
|
|
427
442
|
}
|
|
428
|
-
await committed
|
|
443
|
+
const commitmentError = await committed
|
|
444
|
+
|
|
445
|
+
if (commitmentError) throw commitmentError
|
|
429
446
|
committedAuthority = true
|
|
430
447
|
if (retiredIncumbentControl) {
|
|
431
448
|
await fs.rename(stagingControlPath, this.config.control.path)
|
|
@@ -472,6 +489,13 @@ export default class RollbridgeDaemon {
|
|
|
472
489
|
} else {
|
|
473
490
|
this.guardian.disconnect()
|
|
474
491
|
}
|
|
492
|
+
if (committed) {
|
|
493
|
+
const commitmentError = await committed
|
|
494
|
+
|
|
495
|
+
if (commitmentError && commitmentError !== error && commitmentError.message !== "Process guardian connection closed") {
|
|
496
|
+
abortError = commitmentError
|
|
497
|
+
}
|
|
498
|
+
}
|
|
475
499
|
incumbentControl?.close()
|
|
476
500
|
if (legacyBridge?.boundaryCrossed) {
|
|
477
501
|
this.logger("legacy disruptive owner replacement failed after incumbent exit", {
|
package/src/guardian-client.js
CHANGED
|
@@ -194,9 +194,12 @@ export default class GuardianClient {
|
|
|
194
194
|
await this.request({command: "commit-owner-replacement", replacementId})
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
197
|
+
/**
|
|
198
|
+
* @param {string} replacementId - Same-authority transaction whose incumbent listener is absent.
|
|
199
|
+
* @param {string} key - Exact recovered guardian process proving candidate reconstruction.
|
|
200
|
+
*/
|
|
201
|
+
async commitRetiredOwnerReplacement(replacementId, key) {
|
|
202
|
+
await this.request({command: "commit-retired-owner-replacement", key, replacementId})
|
|
200
203
|
}
|
|
201
204
|
|
|
202
205
|
/** @param {string} replacementId - Committed transaction awaiting incumbent retirement. */
|
package/src/process-guardian.js
CHANGED
|
@@ -300,6 +300,7 @@ async function execute(request, socket) {
|
|
|
300
300
|
|
|
301
301
|
if (request.command === "commit-retired-owner-replacement") {
|
|
302
302
|
requireReplacement(socket, request)
|
|
303
|
+
requireProcess(request)
|
|
303
304
|
if (!replacementOwnerState) throw new Error("Retired owner replacement transaction is not staged")
|
|
304
305
|
if (!isDeepStrictEqual(ownerAuthority(ownerState), replacementAuthority)) throw new Error("Retired owner replacement requires unchanged owner authority")
|
|
305
306
|
const controlPath = ownerControlPath(ownerState)
|
|
@@ -408,10 +409,7 @@ async function execute(request, socket) {
|
|
|
408
409
|
return managedProcess.status()
|
|
409
410
|
}
|
|
410
411
|
|
|
411
|
-
|
|
412
|
-
const record = processes.get(request.key)
|
|
413
|
-
|
|
414
|
-
if (!record) throw new Error(`Guardian process ${request.key} is not registered`)
|
|
412
|
+
const record = requireProcess(request)
|
|
415
413
|
|
|
416
414
|
if (request.command !== "status") requireOwner(socket, request.command)
|
|
417
415
|
|
|
@@ -530,6 +528,18 @@ function requireReplacement(socket, request) {
|
|
|
530
528
|
if (replacementClient !== socket || request.replacementId !== replacementId) throw new Error("Owner replacement transaction is not the prepared candidate")
|
|
531
529
|
}
|
|
532
530
|
|
|
531
|
+
/**
|
|
532
|
+
* @param {GuardianRequest} request - Keyed guardian request.
|
|
533
|
+
* @returns {{desired: boolean, process: ManagedProcess, provenance: string}} Exact registered process.
|
|
534
|
+
*/
|
|
535
|
+
function requireProcess(request) {
|
|
536
|
+
if (!request.key) throw new Error(`Guardian ${request.command} requires a process key`)
|
|
537
|
+
const record = processes.get(request.key)
|
|
538
|
+
|
|
539
|
+
if (!record) throw new Error(`Guardian process ${request.key} is not registered`)
|
|
540
|
+
return record
|
|
541
|
+
}
|
|
542
|
+
|
|
533
543
|
/**
|
|
534
544
|
* @param {import("./json.js").JsonValue} state - Transfer state.
|
|
535
545
|
* @returns {import("./json.js").JsonValue} Embedded authority fence.
|
|
@@ -210,32 +210,68 @@ test("replacement staging rejects owner state published after prepare", async ()
|
|
|
210
210
|
}
|
|
211
211
|
})
|
|
212
212
|
|
|
213
|
+
test("retired owner replacement commit carries its exact recovered process key", async () => {
|
|
214
|
+
const client = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
215
|
+
const replacementId = "prepared-replacement"
|
|
216
|
+
const processKey = "release:v1:worker"
|
|
217
|
+
|
|
218
|
+
client.request = async (request) => {
|
|
219
|
+
if (!request.key) throw new Error(`Guardian ${request.command} requires a process key`)
|
|
220
|
+
assert.deepEqual(request, {command: "commit-retired-owner-replacement", key: processKey, replacementId})
|
|
221
|
+
return {committed: true}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await client.commitRetiredOwnerReplacement(replacementId, processKey)
|
|
225
|
+
})
|
|
226
|
+
|
|
213
227
|
test("retired owner replacement requires unchanged authority and the exact control path absent", async () => {
|
|
214
228
|
const fixture = await createGuardian()
|
|
215
229
|
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
230
|
+
const contender = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
216
231
|
const controlPath = path.join(fixture.root, "rollbridge.sock")
|
|
232
|
+
const processKey = "release:v1:worker"
|
|
217
233
|
const authority = {configDigest: "incumbent", runtime: null}
|
|
218
234
|
const nextAuthority = {configDigest: "candidate", runtime: null}
|
|
219
235
|
const snapshot = {activeReleaseId: "v1", control: {path: controlPath}}
|
|
220
236
|
|
|
221
237
|
try {
|
|
238
|
+
await fixture.client.process(processKey, definition("worker")).recover()
|
|
222
239
|
await fixture.client.publishOwnerState({authority, snapshot})
|
|
223
240
|
await candidate.connect()
|
|
224
241
|
const changed = await candidate.prepareOwnerReplacement(authority, nextAuthority)
|
|
225
242
|
|
|
226
243
|
await candidate.stageOwnerReplacement(changed.replacementId, {authority: nextAuthority, snapshot})
|
|
227
|
-
await assert.rejects(() => candidate.commitRetiredOwnerReplacement(changed.replacementId), /unchanged owner authority/)
|
|
244
|
+
await assert.rejects(() => candidate.commitRetiredOwnerReplacement(changed.replacementId, processKey), /unchanged owner authority/)
|
|
228
245
|
await candidate.abortOwnerReplacement(changed.replacementId)
|
|
229
246
|
|
|
230
247
|
const occupied = await candidate.prepareOwnerReplacement(authority, authority)
|
|
231
248
|
|
|
232
249
|
await candidate.stageOwnerReplacement(occupied.replacementId, {authority, snapshot})
|
|
233
250
|
await fs.writeFile(controlPath, "occupied\n")
|
|
234
|
-
await assert.rejects(() => candidate.commitRetiredOwnerReplacement(occupied.replacementId), /control socket .* still exists/)
|
|
251
|
+
await assert.rejects(() => candidate.commitRetiredOwnerReplacement(occupied.replacementId, processKey), /control socket .* still exists/)
|
|
235
252
|
await candidate.abortOwnerReplacement(occupied.replacementId)
|
|
236
|
-
|
|
253
|
+
|
|
254
|
+
await fs.rm(controlPath)
|
|
255
|
+
const ready = await candidate.prepareOwnerReplacement(authority, authority)
|
|
256
|
+
|
|
257
|
+
await candidate.stageOwnerReplacement(ready.replacementId, {authority, snapshot})
|
|
258
|
+
await contender.connect()
|
|
259
|
+
await assert.rejects(
|
|
260
|
+
() => contender.request({command: "commit-retired-owner-replacement", key: processKey, replacementId: ready.replacementId}),
|
|
261
|
+
/not the prepared candidate/
|
|
262
|
+
)
|
|
263
|
+
await assert.rejects(
|
|
264
|
+
() => candidate.commitRetiredOwnerReplacement(ready.replacementId, "release:v1:wrong"),
|
|
265
|
+
/process .* is not registered/
|
|
266
|
+
)
|
|
267
|
+
const committed = candidate.waitForEvent("replacement-committed")
|
|
268
|
+
|
|
269
|
+
await candidate.commitRetiredOwnerReplacement(ready.replacementId, processKey)
|
|
270
|
+
await committed
|
|
271
|
+
await candidate.shutdown()
|
|
237
272
|
await fixture.client.guardianExit()
|
|
238
273
|
} finally {
|
|
274
|
+
contender.disconnect()
|
|
239
275
|
candidate.disconnect()
|
|
240
276
|
await cleanupGuardian(fixture)
|
|
241
277
|
}
|
|
@@ -249,45 +249,135 @@ test("ensure-daemon atomically replaces incompatible config, socket, and package
|
|
|
249
249
|
}
|
|
250
250
|
})
|
|
251
251
|
|
|
252
|
-
test("
|
|
252
|
+
test("cross-version replacement fails closed without dropping a retained WebSocket after the public socket is removed", async () => {
|
|
253
253
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-retired-control-"))
|
|
254
254
|
const socketPath = path.join(root, "rollbridge.sock")
|
|
255
255
|
const statePath = path.join(root, "state.json")
|
|
256
|
+
const compatibilitySocketPath = path.join(root, "retained-guardian.sock")
|
|
257
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
256
258
|
const releasePath = path.join(root, "v1")
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
let
|
|
259
|
+
const compatibilitySockets = new Set()
|
|
260
|
+
let compatibilityGuardian
|
|
261
|
+
let committedProcessKey
|
|
262
|
+
let owner = /** @type {import("node:child_process").ChildProcess | undefined} */ (undefined)
|
|
263
|
+
let replacement = /** @type {RollbridgeDaemon | undefined} */ (undefined)
|
|
264
|
+
let retainedConnection
|
|
265
|
+
let retainedConnectionClosed = false
|
|
266
|
+
let transactionAudit
|
|
260
267
|
|
|
261
268
|
try {
|
|
262
269
|
await fs.mkdir(releasePath)
|
|
263
270
|
await makeFifo(path.join(releasePath, "worker.fifo"))
|
|
264
|
-
await
|
|
265
|
-
|
|
266
|
-
await
|
|
267
|
-
await
|
|
268
|
-
await
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
assert.deepEqual(processState?.map(({state}) => state), ["quiesced", "quiesced"])
|
|
274
|
-
assert.equal((await owner.guardian?.replacementStatus())?.ownerClaimed, true)
|
|
275
|
-
await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
|
|
271
|
+
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
272
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
273
|
+
await waitForLog(owner, "control socket listening")
|
|
274
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
|
|
275
|
+
const retired = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
276
|
+
const retiredReleases = /** @type {{processes: {id: string, pid?: number, state: string}[]}[]} */ (retired.releases)
|
|
277
|
+
const processState = retiredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
|
|
278
|
+
const proxyPort = /** @type {{port?: number}} */ (retired.proxy).port
|
|
276
279
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
280
|
+
assert.deepEqual(processState?.map(({state}) => state), ["running", "running"])
|
|
281
|
+
if (typeof proxyPort !== "number") throw new Error("Retained owner proxy is missing its port")
|
|
282
|
+
retainedConnection = await openWebSocket(proxyPort)
|
|
283
|
+
retainedConnection.once("close", () => { retainedConnectionClosed = true })
|
|
284
|
+
await fs.rm(socketPath)
|
|
285
|
+
await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
|
|
286
|
+
const state = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
287
|
+
const guardianSocketPath = state.recovery.guardian.socketPath
|
|
288
|
+
const expectedProcessKey = "release:v1:worker"
|
|
289
|
+
|
|
290
|
+
compatibilityGuardian = net.createServer((candidateSocket) => {
|
|
291
|
+
const guardianSocket = net.createConnection(guardianSocketPath)
|
|
292
|
+
let buffer = ""
|
|
293
|
+
|
|
294
|
+
compatibilitySockets.add(candidateSocket)
|
|
295
|
+
compatibilitySockets.add(guardianSocket)
|
|
296
|
+
candidateSocket.setEncoding("utf8")
|
|
297
|
+
candidateSocket.once("close", () => {
|
|
298
|
+
compatibilitySockets.delete(candidateSocket)
|
|
299
|
+
guardianSocket.destroy()
|
|
300
|
+
})
|
|
301
|
+
guardianSocket.once("close", () => {
|
|
302
|
+
compatibilitySockets.delete(guardianSocket)
|
|
303
|
+
candidateSocket.destroy()
|
|
304
|
+
})
|
|
305
|
+
guardianSocket.on("data", (chunk) => candidateSocket.write(chunk))
|
|
306
|
+
candidateSocket.on("data", (chunk) => {
|
|
307
|
+
buffer += chunk
|
|
308
|
+
let newline = buffer.indexOf("\n")
|
|
309
|
+
|
|
310
|
+
while (newline >= 0) {
|
|
311
|
+
const line = buffer.slice(0, newline)
|
|
312
|
+
const request = JSON.parse(line)
|
|
313
|
+
|
|
314
|
+
buffer = buffer.slice(newline + 1)
|
|
315
|
+
if (request.command === "commit-retired-owner-replacement") {
|
|
316
|
+
if (request.key !== expectedProcessKey) {
|
|
317
|
+
candidateSocket.write(`${JSON.stringify({error: `Guardian ${request.command} requires a process key`, id: request.id})}\n`)
|
|
318
|
+
} else {
|
|
319
|
+
committedProcessKey = request.key
|
|
320
|
+
candidateSocket.write(`${JSON.stringify({error: `Guardian ${request.command} requires the committed owner`, id: request.id})}\n`)
|
|
321
|
+
}
|
|
322
|
+
newline = buffer.indexOf("\n")
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
guardianSocket.write(`${line}\n`)
|
|
326
|
+
newline = buffer.indexOf("\n")
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
await listenUnix(compatibilityGuardian, compatibilitySocketPath)
|
|
331
|
+
state.recovery.guardian.socketPath = compatibilitySocketPath
|
|
332
|
+
await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
|
|
333
|
+
|
|
334
|
+
const incumbentPid = owner.pid
|
|
335
|
+
|
|
336
|
+
assert.ok(incumbentPid)
|
|
337
|
+
const candidate = new RollbridgeDaemon({
|
|
338
|
+
config: normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath})),
|
|
339
|
+
configPath,
|
|
340
|
+
legacyIncumbentPid: incumbentPid,
|
|
341
|
+
logger: () => {}
|
|
342
|
+
})
|
|
343
|
+
replacement = candidate
|
|
344
|
+
await assert.rejects(
|
|
345
|
+
() => candidate.replaceIncompatibleOwner(),
|
|
346
|
+
/cannot safely complete atomic owner replacement through the older retained guardian while the incumbent control socket is absent; incumbent owner and connections were preserved/i
|
|
347
|
+
)
|
|
348
|
+
assert.equal(committedProcessKey, expectedProcessKey)
|
|
349
|
+
assert.equal(owner.exitCode, null)
|
|
350
|
+
assert.equal(owner.signalCode, null)
|
|
351
|
+
assert.doesNotThrow(() => process.kill(incumbentPid, 0))
|
|
352
|
+
assert.equal(retainedConnectionClosed, false, "failed compatibility handoff must leave retained connections serving")
|
|
353
|
+
assert.equal(retainedConnection.destroyed, false, "failed compatibility handoff must preserve the incumbent listener")
|
|
354
|
+
for (const {pid} of processState || []) {
|
|
355
|
+
if (typeof pid !== "number") throw new Error("Retained process is missing its PID")
|
|
356
|
+
assert.doesNotThrow(() => process.kill(pid, 0))
|
|
357
|
+
}
|
|
358
|
+
transactionAudit = new GuardianClient(state.recovery.guardian)
|
|
359
|
+
await transactionAudit.connect()
|
|
360
|
+
const transactionStatus = /** @type {{committedReplacementId: string | null, ownerClaimed: boolean, retirementPending?: boolean}} */ (await transactionAudit.replacementStatus())
|
|
281
361
|
|
|
282
|
-
assert.equal(
|
|
283
|
-
assert.
|
|
284
|
-
assert.
|
|
362
|
+
assert.equal(transactionStatus.committedReplacementId, null)
|
|
363
|
+
assert.equal(transactionStatus.ownerClaimed, true)
|
|
364
|
+
assert.equal(transactionStatus.retirementPending, false)
|
|
285
365
|
} finally {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
366
|
+
if (replacement?.controlCommandsReady) {
|
|
367
|
+
await Promise.all([
|
|
368
|
+
fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}),
|
|
369
|
+
replacement.shutdown().catch(() => {})
|
|
370
|
+
])
|
|
371
|
+
}
|
|
372
|
+
transactionAudit?.disconnect()
|
|
373
|
+
retainedConnection?.destroy()
|
|
374
|
+
if (owner && owner.exitCode === null && owner.signalCode === null) {
|
|
375
|
+
owner.kill("SIGKILL")
|
|
376
|
+
await once(owner, "exit")
|
|
377
|
+
}
|
|
290
378
|
replacement?.guardian?.disconnect()
|
|
379
|
+
for (const socket of compatibilitySockets) socket.destroy()
|
|
380
|
+
if (compatibilityGuardian?.listening) await closeServer(compatibilityGuardian)
|
|
291
381
|
await stopGuardian(statePath)
|
|
292
382
|
await fs.rm(root, {force: true, recursive: true})
|
|
293
383
|
}
|