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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
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
@@ -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
- const committed = this.guardian.waitForEvent("replacement-committed")
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
- await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId)
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", {
@@ -194,9 +194,12 @@ export default class GuardianClient {
194
194
  await this.request({command: "commit-owner-replacement", replacementId})
195
195
  }
196
196
 
197
- /** @param {string} replacementId - Same-authority transaction whose incumbent listener is absent. */
198
- async commitRetiredOwnerReplacement(replacementId) {
199
- await this.request({command: "commit-retired-owner-replacement", replacementId})
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. */
@@ -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
- if (!request.key) throw new Error(`Guardian ${request.command} requires a process key`)
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
- await fixture.client.shutdown()
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("same-authority replacement commits after a retired incumbent already removed its control socket", async () => {
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 daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath}))
258
- const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
259
- let replacement
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 owner.start()
265
- await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
266
- await Promise.all([...owner.releases.values()].map((release) => release.quiesce()))
267
- await owner.closeServer(owner.controlServer)
268
- await owner.removeControlSocket()
269
- await owner.closeServer(owner.proxyServer)
270
- const retired = owner.status()
271
- const processState = retired.releases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
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
- replacement = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
278
- await replacement.replaceIncompatibleOwner()
279
- const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
280
- const recoveredReleases = /** @type {{processes: {id: string, pid?: number, state: string}[]}[]} */ (recovered.releases)
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(recovered.activeReleaseId, "v1")
283
- assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
284
- assert.deepEqual(recoveredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state})), processState)
362
+ assert.equal(transactionStatus.committedReplacementId, null)
363
+ assert.equal(transactionStatus.ownerClaimed, true)
364
+ assert.equal(transactionStatus.retirementPending, false)
285
365
  } finally {
286
- const shutdown = replacement?.controlCommandsReady ? replacement.shutdown().catch(() => {}) : owner.shutdown().catch(() => {})
287
-
288
- await Promise.all([fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}), shutdown])
289
- owner.guardian?.disconnect()
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
  }