rollbridge 0.1.33 → 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.
@@ -3,3 +3,6 @@
3
3
  - Carry an exact recovered guardian process key when a ready replacement commits
4
4
  after the incumbent control listener has already retired, while retaining the
5
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.33",
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,
@@ -415,7 +419,15 @@ export default class RollbridgeDaemon {
415
419
  const processKey = this.guardian.processes.keys().next().value
416
420
 
417
421
  if (!processKey) throw new Error("Retired owner replacement requires an exact recovered guardian process registration")
418
- await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId, processKey)
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
+ }
419
431
  } else {
420
432
  try {
421
433
  if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
@@ -428,7 +440,9 @@ export default class RollbridgeDaemon {
428
440
  }
429
441
  }
430
442
  }
431
- await committed
443
+ const commitmentError = await committed
444
+
445
+ if (commitmentError) throw commitmentError
432
446
  committedAuthority = true
433
447
  if (retiredIncumbentControl) {
434
448
  await fs.rename(stagingControlPath, this.config.control.path)
@@ -475,6 +489,13 @@ export default class RollbridgeDaemon {
475
489
  } else {
476
490
  this.guardian.disconnect()
477
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
+ }
478
499
  incumbentControl?.close()
479
500
  if (legacyBridge?.boundaryCrossed) {
480
501
  this.logger("legacy disruptive owner replacement failed after incumbent exit", {
@@ -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
  }