rollbridge 0.1.35 → 0.1.36

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.
@@ -1,9 +1,9 @@
1
1
  ### Fixed
2
2
 
3
3
  - Select retired-owner commit proof from the guardian-published committed owner
4
- snapshot rather than candidate-local process order, and independently require
5
- that proof to remain registered while retaining the replacement transaction,
6
- authority, and control-path fences.
4
+ snapshot rather than candidate-local process order, reserve its incumbent-owned
5
+ registration through commit, then attach it to the committed candidate while
6
+ retaining the replacement transaction, authority, and control-path fences.
7
7
  - Fail closed when an older retained guardian cannot commit that replacement
8
8
  atomically after the incumbent control socket disappears, preserving the
9
9
  incumbent owner, retained connections, and guardian-managed release processes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/daemon.js CHANGED
@@ -342,6 +342,11 @@ export default class RollbridgeDaemon {
342
342
  const transfer = /** @type {PrivateOwnerState} */ (prepared.ownerState)
343
343
 
344
344
  if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
345
+ const registeredProcesses = new Map((await this.guardian.inventory()).map(({key, provenance}) => [key, provenance]))
346
+ const reservedProcessKey = legacyBridge ? undefined : ownerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
347
+ .find((key) => registeredProcesses.has(key))
348
+
349
+ if (reservedProcessKey) this.guardian.reserveProcessRecovery(reservedProcessKey, /** @type {string} */ (registeredProcesses.get(reservedProcessKey)))
345
350
  await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false, singletonReleaseIds: transfer.singletonReleaseIds, synchronizeLifecycleRoles: false})
346
351
  for (const release of this.releases.values()) release.preserveConfigOnRetirement = true
347
352
  this.logger("owner replacement candidate prepared", {activeReleaseId: this.activeRelease?.releaseId ?? null, replacementId: prepared.replacementId})
@@ -386,6 +391,7 @@ export default class RollbridgeDaemon {
386
391
  if (incumbentControl) {
387
392
  const listenerSession = incumbentControl
388
393
 
394
+ if (reservedProcessKey) await this.guardian.recoverReservedProcess(reservedProcessKey)
389
395
  incumbentControl.onEvent((event) => this.handleIncumbentListenerEvent(event, listenerSession))
390
396
  await incumbentControl.request({
391
397
  command: "yield-owner-listeners",
@@ -414,15 +420,20 @@ export default class RollbridgeDaemon {
414
420
  snapshot: this.status()
415
421
  })
416
422
 
423
+ if (staged.committed && reservedProcessKey) {
424
+ committedAuthority = true
425
+ await this.guardian.recoverReservedProcess(reservedProcessKey)
426
+ }
427
+
417
428
  if (!staged.committed) {
418
429
  if (retiredIncumbentControl) {
419
- const recoveredProcesses = this.guardian.processes
420
- const processKey = ownerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
421
- .find((key) => recoveredProcesses.has(key))
430
+ const processKey = reservedProcessKey
422
431
 
423
- if (!processKey) throw new Error("Retired owner replacement requires an exact recovered process from committed owner state")
432
+ if (!processKey || !this.guardian.processes.has(processKey)) throw new Error("Retired owner replacement requires an exact reserved process from committed owner state")
424
433
  try {
425
434
  await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId, processKey)
435
+ committedAuthority = true
436
+ await this.guardian.recoverReservedProcess(processKey)
426
437
  } catch (error) {
427
438
  if (!(error instanceof Error) || error.message !== "Guardian commit-retired-owner-replacement requires the committed owner") throw error
428
439
  throw new Error(
@@ -21,6 +21,8 @@ export default class GuardianClient {
21
21
  this.idleWaiters = /** @type {(() => void)[]} */ ([])
22
22
  this.guardianExitPromise = /** @type {Promise<void> | undefined} */ (undefined)
23
23
  this.processes = /** @type {Map<string, GuardianProcess>} */ (new Map())
24
+ this.reservedProcessKey = /** @type {string | undefined} */ (undefined)
25
+ this.reservedProcessProvenance = /** @type {string | undefined} */ (undefined)
24
26
  this.events = /** @type {Map<string, {reject: (error: Error) => void, resolve: (value: Record<string, import("./json.js").JsonValue>) => void}[]>} */ (new Map())
25
27
  this.eventHandlers = /** @type {Map<string, ((event: Record<string, import("./json.js").JsonValue>) => void)[]>} */ (new Map())
26
28
  }
@@ -111,6 +113,27 @@ export default class GuardianClient {
111
113
  return processInstance
112
114
  }
113
115
 
116
+ /**
117
+ * @param {string} key - Exact committed-owner registration reserved until replacement commit.
118
+ * @param {string} provenance - Guardian-inventoried definition fence.
119
+ */
120
+ reserveProcessRecovery(key, provenance) {
121
+ if (this.reservedProcessKey) throw new Error(`Guardian process recovery ${this.reservedProcessKey} is already reserved`)
122
+ this.reservedProcessKey = key
123
+ this.reservedProcessProvenance = provenance
124
+ }
125
+
126
+ /** @param {string} key - Exact reserved registration to attach after authority commits. */
127
+ async recoverReservedProcess(key) {
128
+ if (this.reservedProcessKey !== key) throw new Error(`Guardian process recovery ${key} is not reserved`)
129
+ const processInstance = this.processes.get(key)
130
+
131
+ if (!processInstance) throw new Error(`Reserved guardian process ${key} was not reconstructed`)
132
+ await processInstance.attachReserved()
133
+ this.reservedProcessKey = undefined
134
+ this.reservedProcessProvenance = undefined
135
+ }
136
+
114
137
  /**
115
138
  * @param {Record<string, import("./json.js").JsonValue>} command - Command.
116
139
  * @returns {Promise<import("./json.js").JsonValue>} Guardian response.
@@ -354,6 +377,16 @@ class GuardianProcess extends ManagedProcess {
354
377
 
355
378
  /** Reconnects to an already registered guardian process without changing its desired state. */
356
379
  async recover() {
380
+ if (this.client.reservedProcessKey === this.key) {
381
+ if (this.client.reservedProcessProvenance !== this.provenance) throw new Error(`Guardian provenance mismatch for reserved process ${this.key}`)
382
+ this.cachedStatus = asProcessStatus(await this.client.request({command: "status", key: this.key}))
383
+ return
384
+ }
385
+ await this.ensureRegistered()
386
+ }
387
+
388
+ /** Attaches a reconstructed process whose incumbent-owned registration was reserved through commit. */
389
+ async attachReserved() {
357
390
  await this.ensureRegistered()
358
391
  }
359
392
 
@@ -224,6 +224,29 @@ test("retired owner replacement commit carries its exact recovered process key",
224
224
  await client.commitRetiredOwnerReplacement(replacementId, processKey)
225
225
  })
226
226
 
227
+ test("reserved process recovery rejects a reconstructed definition with different provenance", async () => {
228
+ const fixture = await createGuardian()
229
+ const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
230
+ const processKey = "release:v1:worker"
231
+
232
+ try {
233
+ await fixture.client.process(processKey, definition("worker")).recover()
234
+ const [registration] = await fixture.client.inventory()
235
+
236
+ assert.ok(registration)
237
+ await candidate.connect()
238
+ candidate.reserveProcessRecovery(processKey, registration.provenance)
239
+ await assert.rejects(
240
+ () => candidate.process(processKey, definition("different-worker")).recover(),
241
+ /provenance mismatch for reserved process/
242
+ )
243
+ assert.deepEqual((await fixture.client.inventory()).map(({key}) => key), [processKey])
244
+ } finally {
245
+ candidate.disconnect()
246
+ await cleanupGuardian(fixture)
247
+ }
248
+ })
249
+
227
250
  test("retired owner replacement rejects a registered process absent from committed owner state", async () => {
228
251
  const fixture = await createGuardian()
229
252
  const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
@@ -393,16 +393,24 @@ test("cross-version replacement fails closed without dropping a retained WebSock
393
393
  }
394
394
  })
395
395
 
396
- test("same-authority replacement commits with committed-owner proof after the incumbent control socket is removed", async () => {
396
+ test("cross-version replacement preserves committed-owner proof until commit then recovers every process", async () => {
397
397
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-committed-proof-"))
398
398
  const socketPath = path.join(root, "rollbridge.sock")
399
399
  const statePath = path.join(root, "state.json")
400
+ const compatibilitySocketPath = path.join(root, "retained-guardian.sock")
400
401
  const releasePath = path.join(root, "v1")
401
402
  const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath}))
402
403
  const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
404
+ const compatibilitySockets = new Set()
403
405
  const candidateProcessKey = "release:candidate:worker"
404
406
  const committedOwnerProcessKey = "release:v1:worker"
407
+ const candidateRecoveredKeys = new Set()
408
+ let compatibilityGuardian
405
409
  let committedProcessKey
410
+ let recoveredKeysAtCommit = /** @type {Set<string> | undefined} */ (undefined)
411
+ let retainedConnection
412
+ let retainedConnectionClosed = false
413
+ let retainedGuardianSocketPath = /** @type {string | undefined} */ (undefined)
406
414
  /** @type {RollbridgeDaemon | undefined} */
407
415
  let replacement
408
416
 
@@ -421,14 +429,66 @@ test("same-authority replacement commits with committed-owner proof after the in
421
429
  key: candidateProcessKey,
422
430
  provenance: ownerProcess.provenance
423
431
  })
424
- await Promise.all([...owner.releases.values()].map((release) => release.quiesce()))
432
+ const running = owner.status()
433
+ const processState = running.releases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
434
+ const expectedProcessKeys = new Set(running.releases[0]?.processes.map(({id}) => `release:v1:${id}`))
435
+ const proxyPort = /** @type {{port?: number}} */ (running.proxy).port
436
+
437
+ assert.deepEqual(processState?.map(({state}) => state), ["running", "running"])
438
+ if (typeof proxyPort !== "number") throw new Error("Retained owner proxy is missing its port")
439
+ retainedConnection = await openWebSocket(proxyPort)
440
+ retainedConnection.once("close", () => { retainedConnectionClosed = true })
425
441
  await owner.closeServer(owner.controlServer)
426
442
  await owner.removeControlSocket()
427
- await owner.closeServer(owner.proxyServer)
428
- const retired = owner.status()
429
- const processState = retired.releases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
443
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"))
444
+ const guardianSocketPath = state.recovery.guardian.socketPath
445
+
446
+ if (typeof guardianSocketPath !== "string") throw new Error("Retained guardian state is missing its socket path")
447
+ retainedGuardianSocketPath = guardianSocketPath
448
+ compatibilityGuardian = net.createServer((candidateSocket) => {
449
+ const guardianSocket = net.createConnection(guardianSocketPath)
450
+ let buffer = ""
451
+
452
+ compatibilitySockets.add(candidateSocket)
453
+ compatibilitySockets.add(guardianSocket)
454
+ candidateSocket.setEncoding("utf8")
455
+ candidateSocket.once("close", () => {
456
+ compatibilitySockets.delete(candidateSocket)
457
+ guardianSocket.destroy()
458
+ })
459
+ guardianSocket.once("close", () => {
460
+ compatibilitySockets.delete(guardianSocket)
461
+ candidateSocket.destroy()
462
+ })
463
+ guardianSocket.on("data", (chunk) => candidateSocket.write(chunk))
464
+ candidateSocket.on("data", (chunk) => {
465
+ buffer += chunk
466
+ let newline = buffer.indexOf("\n")
467
+
468
+ while (newline >= 0) {
469
+ const line = buffer.slice(0, newline)
470
+ const request = JSON.parse(line)
471
+
472
+ buffer = buffer.slice(newline + 1)
473
+ if (request.command === "register") candidateRecoveredKeys.add(request.key)
474
+ if (request.command === "commit-retired-owner-replacement") {
475
+ committedProcessKey = request.key
476
+ recoveredKeysAtCommit = new Set(candidateRecoveredKeys)
477
+ if (candidateRecoveredKeys.has(request.key)) {
478
+ candidateSocket.write(`${JSON.stringify({error: `Guardian ${request.command} requires the committed owner`, id: request.id})}\n`)
479
+ newline = buffer.indexOf("\n")
480
+ continue
481
+ }
482
+ }
483
+ guardianSocket.write(`${line}\n`)
484
+ newline = buffer.indexOf("\n")
485
+ }
486
+ })
487
+ })
488
+ await listenUnix(compatibilityGuardian, compatibilitySocketPath)
489
+ state.recovery.guardian.socketPath = compatibilitySocketPath
490
+ await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
430
491
 
431
- assert.deepEqual(processState?.map(({state}) => state), ["quiesced", "quiesced"])
432
492
  assert.equal((await owner.guardian?.replacementStatus())?.ownerClaimed, true)
433
493
  await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
434
494
 
@@ -455,14 +515,33 @@ test("same-authority replacement commits with committed-owner proof after the in
455
515
 
456
516
  assert.equal(recovered.activeReleaseId, "v1")
457
517
  assert.equal(committedProcessKey, committedOwnerProcessKey)
518
+ assert.equal(recoveredKeysAtCommit?.has(committedOwnerProcessKey), false)
519
+ assert.deepEqual(candidateRecoveredKeys, expectedProcessKeys)
520
+ assert.equal([...replacement.guardian?.processes.keys() || []][0], candidateProcessKey)
521
+ assert.equal(retainedConnectionClosed, false, "successful compatibility handoff must preserve retained connections")
522
+ assert.equal(retainedConnection.destroyed, false, "successful compatibility handoff must leave the retained listener serving")
458
523
  assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
459
524
  assert.deepEqual(recoveredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state})), processState)
525
+ for (const {pid} of processState || []) {
526
+ if (typeof pid !== "number") throw new Error("Retained process is missing its PID")
527
+ assert.doesNotThrow(() => process.kill(pid, 0))
528
+ }
460
529
  } finally {
530
+ if (retainedGuardianSocketPath) {
531
+ const cleanupState = JSON.parse(await fs.readFile(statePath, "utf8"))
532
+
533
+ cleanupState.recovery.guardian.socketPath = retainedGuardianSocketPath
534
+ await fs.writeFile(statePath, `${JSON.stringify(cleanupState)}\n`)
535
+ }
461
536
  const shutdown = replacement?.controlCommandsReady ? replacement.shutdown().catch(() => {}) : owner.shutdown().catch(() => {})
462
537
 
538
+ retainedConnection?.destroy()
539
+ await owner.closeServer(owner.proxyServer)
463
540
  await Promise.all([fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}), shutdown])
464
541
  owner.guardian?.disconnect()
465
542
  replacement?.guardian?.disconnect()
543
+ for (const socket of compatibilitySockets) socket.destroy()
544
+ if (compatibilityGuardian?.listening) await closeServer(compatibilityGuardian)
466
545
  await stopGuardian(statePath)
467
546
  await fs.rm(root, {force: true, recursive: true})
468
547
  }