rollbridge 0.1.39 → 0.1.41

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.
@@ -14,6 +14,7 @@ import {openControlSession, sendControlCommand} from "../src/control-client.js"
14
14
  import RollbridgeDaemon, {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
15
15
  import GuardianClient from "../src/guardian-client.js"
16
16
  import {findAvailablePort} from "../src/port-allocator.js"
17
+ import {waitForProcessExit} from "./support/process.js"
17
18
 
18
19
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
19
20
  const binPath = path.join(repoRoot, "bin", "rollbridge")
@@ -512,7 +513,7 @@ test("first pre-split package upgrade is explicitly disruptive and later replace
512
513
  await fs.mkdir(releasePath)
513
514
  await makeFifo(path.join(releasePath, "worker.fifo"))
514
515
  await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, proxyPort, statePath}))
515
- owner = spawn(process.execPath, [legacyDaemonPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
516
+ owner = spawn(process.execPath, [legacyDaemonPath, "daemon", "--config", path.basename(configPath)], {cwd: root, stdio: ["ignore", "pipe", "pipe"]})
516
517
  await waitForLog(owner, "control socket listening")
517
518
  assert.ok(owner.pid)
518
519
  await fs.writeFile(daemonPidPath, `${owner.pid}\n`)
@@ -582,7 +583,7 @@ test("first pre-split package upgrade is explicitly disruptive and later replace
582
583
 
583
584
  assert.equal(releaseProcessPid(replaced, "v1", "worker"), legacyWorkerPid)
584
585
  assert.equal(replaced.activeReleaseId, "v1")
585
- retainedConnection.destroy()
586
+ retainedConnection.resetAndDestroy()
586
587
  const shutdown = sendControlCommand({command: {command: "shutdown"}, path: nextSocketPath})
587
588
  await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
588
589
  await shutdown
@@ -606,6 +607,63 @@ test("first pre-split package upgrade is explicitly disruptive and later replace
606
607
  }
607
608
  })
608
609
 
610
+ test("replacement-capable guardian without daemon recovery aborts before handoff and resumes incumbent drains", async () => {
611
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-intermediate-"))
612
+ const socketPath = path.join(root, "rollbridge.sock")
613
+ const statePath = path.join(root, "state.json")
614
+ const configPath = path.join(root, "rollbridge.cjs")
615
+ const daemonPidPath = path.join(root, "daemon.pid")
616
+ const daemonLogPath = path.join(root, "candidate.log")
617
+ const runtimePath = path.join(root, "runtime")
618
+ const intermediatePackagePath = path.join(root, "intermediate-package")
619
+ const abortedPath = path.join(root, "replacement-aborted")
620
+ const preparedPath = path.join(root, "replacement-prepared")
621
+ const v1Path = path.join(root, "v1")
622
+ const v2Path = path.join(root, "v2")
623
+ let owner
624
+ let retainedConnection
625
+
626
+ try {
627
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path), prepareCandidatePackage(intermediatePackagePath)])
628
+ await Promise.all([makeFifo(path.join(v1Path, "worker.fifo")), makeFifo(path.join(v2Path, "worker.fifo"))])
629
+ await removeDaemonRecoveryCapability(intermediatePackagePath, {abortedPath, preparedPath})
630
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
631
+ owner = spawn(process.execPath, [path.join(intermediatePackagePath, "bin", "rollbridge"), "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
632
+ await waitForLog(owner, "control socket listening")
633
+ assert.ok(owner.pid)
634
+ await fs.writeFile(daemonPidPath, `${owner.pid}\n`)
635
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: socketPath})
636
+ const active = await sendControlCommand({command: {command: "status"}, path: socketPath})
637
+
638
+ retainedConnection = await openWebSocket(/** @type {{port: number}} */ (active.proxy).port)
639
+ await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: socketPath})
640
+ const before = await sendControlCommand({command: {command: "status"}, path: socketPath})
641
+ const workerPid = releaseProcessPid(before, "v1", "worker")
642
+ const replacement = await runEnsureDaemon({configPath, daemonPidPath, logPath: daemonLogPath, packagePath: repoRoot, runtimePath})
643
+
644
+ assert.equal(replacement.code, 1)
645
+ assert.match(await fs.readFile(daemonLogPath, "utf8"), /persistent Rollbridge guardian predates daemon recovery/)
646
+ await waitForFile(abortedPath)
647
+ const preserved = await sendControlCommand({command: {command: "status"}, path: socketPath})
648
+
649
+ assert.equal(preserved.daemonPid, before.daemonPid, "capability rejection must leave the incumbent daemon serving")
650
+ assert.equal(releaseProcessPid(preserved, "v1", "worker"), workerPid)
651
+ retainedConnection.destroy()
652
+ retainedConnection = undefined
653
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
654
+ await waitForReleaseState(socketPath, "v1", "stopped")
655
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
656
+
657
+ await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
658
+ await shutdown
659
+ } finally {
660
+ retainedConnection?.destroy()
661
+ if (owner && owner.exitCode === null && owner.signalCode === null) owner.kill("SIGKILL")
662
+ await stopGuardian(statePath)
663
+ await fs.rm(root, {force: true, recursive: true})
664
+ }
665
+ })
666
+
609
667
  test("ensure-daemon owns and reports the exact candidate exit before readiness", async () => {
610
668
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-candidate-exit-"))
611
669
  const configPath = path.join(root, "rollbridge.cjs")
@@ -614,6 +672,7 @@ test("ensure-daemon owns and reports the exact candidate exit before readiness",
614
672
  const runtimePath = path.join(root, "runtime")
615
673
  const socketPath = path.join(root, "rollbridge.sock")
616
674
  const statePath = path.join(root, "state.json")
675
+ let candidate
617
676
 
618
677
  try {
619
678
  await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
@@ -624,15 +683,24 @@ test("ensure-daemon owns and reports the exact candidate exit before readiness",
624
683
  "--daemon-runtime-path", runtimePath, "--daemon-log-path", path.join(root, "daemon.log"),
625
684
  "--daemon-pid-path", path.join(root, "daemon.pid"), "--daemon-start-timeout-ms", "3000"
626
685
  ])
627
- const candidate = JSON.parse(await fs.readFile(evidencePath, "utf8"))
686
+ candidate = JSON.parse(await fs.readFile(evidencePath, "utf8"))
628
687
 
629
688
  assert.equal(candidate.ppid, ensured.pid, "the recorded process must be the candidate spawned by this exact ensuring CLI")
630
689
  assert.notEqual(candidate.pid, ensured.pid)
631
- assert.deepEqual(candidate.argv.slice(2), ["daemon", "--config", configPath])
690
+ assert.deepEqual(candidate.argv.slice(2), [
691
+ "daemon", "--config", configPath,
692
+ "--guardian-daemon-log-path", path.join(root, "daemon.log"),
693
+ "--guardian-daemon-pid-path", path.join(root, "daemon.pid"),
694
+ "--guardian-daemon-start-timeout-ms", "3000"
695
+ ])
632
696
  assert.equal(ensured.code, 1)
633
697
  assert.match(ensured.stderr, new RegExp(`Rollbridge daemon candidate ${candidate.pid} exited before readiness \\(code 47, signal none\\)`))
634
698
  assert.doesNotMatch(ensured.stderr, /did not become ready within/)
699
+ await waitForProcessExit(candidate.descendantPid)
635
700
  } finally {
701
+ if (candidate?.descendantPid) {
702
+ try { process.kill(candidate.descendantPid, "SIGKILL") } catch (_error) { /* Exact candidate descendant already exited. */ }
703
+ }
636
704
  await fs.rm(root, {force: true, recursive: true})
637
705
  }
638
706
  })
@@ -786,7 +854,7 @@ test("cross-version replacement fails closed without dropping a retained WebSock
786
854
  const request = JSON.parse(line)
787
855
 
788
856
  buffer = buffer.slice(newline + 1)
789
- if (request.command === "commit-retired-owner-replacement") {
857
+ if (["commit-retired-owner-replacement", "prepare-retired-owner-listener-handoff"].includes(request.command)) {
790
858
  if (request.key !== expectedProcessKey) {
791
859
  candidateSocket.write(`${JSON.stringify({error: `Guardian ${request.command} requires a process key`, id: request.id})}\n`)
792
860
  } else {
@@ -869,7 +937,9 @@ test("cross-version replacement preserves committed-owner proof until commit the
869
937
  const statePath = path.join(root, "state.json")
870
938
  const compatibilitySocketPath = path.join(root, "retained-guardian.sock")
871
939
  const releasePath = path.join(root, "v1")
872
- const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath}))
940
+ const nextReleasePath = path.join(root, "v2")
941
+ const proxyPort = await findAvailablePort({host: "127.0.0.1", range: {from: 26000, to: 26999}, usedPorts: new Set()})
942
+ const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, proxyPort, statePath}))
873
943
  const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
874
944
  const compatibilitySockets = new Set()
875
945
  const candidateProcessKey = "release:candidate:worker"
@@ -880,13 +950,16 @@ test("cross-version replacement preserves committed-owner proof until commit the
880
950
  let recoveredKeysAtCommit = /** @type {Set<string> | undefined} */ (undefined)
881
951
  let retainedConnection
882
952
  let retainedConnectionClosed = false
953
+ let secondRetainedConnection
883
954
  let retainedGuardianSocketPath = /** @type {string | undefined} */ (undefined)
884
955
  /** @type {RollbridgeDaemon | undefined} */
956
+ let intermediate
957
+ /** @type {RollbridgeDaemon | undefined} */
885
958
  let replacement
886
959
 
887
960
  try {
888
- await fs.mkdir(releasePath)
889
- await makeFifo(path.join(releasePath, "worker.fifo"))
961
+ await Promise.all([fs.mkdir(releasePath), fs.mkdir(nextReleasePath)])
962
+ await Promise.all([makeFifo(path.join(releasePath, "worker.fifo")), makeFifo(path.join(nextReleasePath, "worker.fifo"))])
890
963
  await owner.start()
891
964
  await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
892
965
  const ownerProcess = owner.guardian?.processes.values().next().value
@@ -902,14 +975,14 @@ test("cross-version replacement preserves committed-owner proof until commit the
902
975
  const running = owner.status()
903
976
  const processState = running.releases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
904
977
  const expectedProcessKeys = new Set(running.releases[0]?.processes.map(({id}) => `release:v1:${id}`))
905
- const proxyPort = /** @type {{port?: number}} */ (running.proxy).port
978
+ const runningProxyPort = /** @type {{port?: number}} */ (running.proxy).port
906
979
 
907
980
  assert.deepEqual(processState?.map(({state}) => state), ["running", "running"])
908
- if (typeof proxyPort !== "number") throw new Error("Retained owner proxy is missing its port")
909
- retainedConnection = await openWebSocket(proxyPort)
981
+ if (typeof runningProxyPort !== "number") throw new Error("Retained owner proxy is missing its port")
982
+ retainedConnection = await openWebSocket(runningProxyPort)
910
983
  retainedConnection.once("close", () => { retainedConnectionClosed = true })
911
984
  await owner.closeServer(owner.controlServer)
912
- await owner.removeControlSocket()
985
+ await fs.rm(socketPath, {force: true})
913
986
  const state = JSON.parse(await fs.readFile(statePath, "utf8"))
914
987
  const guardianSocketPath = state.recovery.guardian.socketPath
915
988
 
@@ -980,9 +1053,14 @@ test("cross-version replacement preserves committed-owner proof until commit the
980
1053
  }
981
1054
  })
982
1055
  await replacement.replaceIncompatibleOwner()
1056
+ const retirementDeadline = Date.now() + 1000
1057
+
1058
+ while (!owner.ownerRetired && Date.now() < retirementDeadline) await new Promise((resolve) => setTimeout(resolve, 10))
983
1059
  const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
984
- const recoveredReleases = /** @type {{processes: {id: string, pid?: number, state: string}[]}[]} */ (recovered.releases)
1060
+ const recoveredReleases = /** @type {{connectionCount: number, connections: {http: number, websocket: number}, processes: {id: string, pid?: number, state: string}[], releaseId: string, state: string}[]} */ (recovered.releases)
985
1061
 
1062
+ assert.equal(owner.ownerRetired, true, "committed incumbent must observe retirement without a public control socket")
1063
+ assert.equal(owner.proxyServer?.listening, false, "committed incumbent must stop accepting stale proxy traffic")
986
1064
  assert.equal(recovered.activeReleaseId, "v1")
987
1065
  assert.equal(committedProcessKey, committedOwnerProcessKey)
988
1066
  assert.equal(recoveredKeysAtCommit?.has(committedOwnerProcessKey), false)
@@ -990,12 +1068,62 @@ test("cross-version replacement preserves committed-owner proof until commit the
990
1068
  assert.equal([...replacement.guardian?.processes.keys() || []][0], candidateProcessKey)
991
1069
  assert.equal(retainedConnectionClosed, false, "successful compatibility handoff must preserve retained connections")
992
1070
  assert.equal(retainedConnection.destroyed, false, "successful compatibility handoff must leave the retained listener serving")
1071
+ assert.deepEqual(recoveredReleases[0]?.connections, {http: 0, websocket: 1}, "candidate must inherit exact live incumbent connection counts")
993
1072
  assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
994
1073
  assert.deepEqual(recoveredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state})), processState)
995
1074
  for (const {pid} of processState || []) {
996
1075
  if (typeof pid !== "number") throw new Error("Retained process is missing its PID")
997
1076
  assert.doesNotThrow(() => process.kill(pid, 0))
998
1077
  }
1078
+ secondRetainedConnection = await openWebSocket(runningProxyPort)
1079
+ intermediate = replacement
1080
+ const directGuardianState = JSON.parse(await fs.readFile(statePath, "utf8"))
1081
+
1082
+ directGuardianState.recovery.guardian.socketPath = guardianSocketPath
1083
+ await fs.writeFile(statePath, `${JSON.stringify(directGuardianState)}\n`)
1084
+ await intermediate.closeServer(intermediate.controlServer)
1085
+ await fs.rm(socketPath, {force: true})
1086
+ replacement = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
1087
+ await replacement.replaceIncompatibleOwner()
1088
+ const repeatedHandoff = /** @type {{connections: {http: number, websocket: number}, releaseId: string}[]} */ ((await sendControlCommand({command: {command: "status"}, path: socketPath})).releases)
1089
+
1090
+ assert.deepEqual(repeatedHandoff[0]?.connections, {http: 0, websocket: 2}, "successive control-less owners must aggregate each physical listener source")
1091
+ await replacement.deploy({releaseId: "v2", releasePath: nextReleasePath, revision: "v2"})
1092
+ const draining = /** @type {{connectionCount: number, releaseId: string, state: string}[]} */ ((await sendControlCommand({command: {command: "status"}, path: socketPath})).releases)
1093
+ const retainedRelease = draining.find(({releaseId}) => releaseId === "v1")
1094
+
1095
+ assert.equal(retainedRelease?.state, "draining")
1096
+ assert.equal(retainedRelease?.connectionCount, 2, "candidate must not stop the retained upstream while retired WebSockets are live")
1097
+ // Send a masked empty WebSocket close frame so both retained proxy legs drain.
1098
+ retainedConnection.write(Buffer.from([0x88, 0x80, 0, 0, 0, 0]))
1099
+ if (owner.proxyClosePromise) await owner.proxyClosePromise
1100
+ if (!retainedConnectionClosed) await once(retainedConnection, "close")
1101
+ assert.equal(retainedConnectionClosed, true, "retired incumbent must finish after its retained connections drain")
1102
+ const oneSourceDeadline = Date.now() + 3000
1103
+ let oneRetained
1104
+
1105
+ while (Date.now() < oneSourceDeadline) {
1106
+ oneRetained = /** @type {{connectionCount: number, processes: {id: string, state: string}[], releaseId: string}[]} */ ((await sendControlCommand({command: {command: "status"}, path: socketPath})).releases)
1107
+ .find(({releaseId}) => releaseId === "v1")
1108
+ if (oneRetained?.connectionCount === 1) break
1109
+ await new Promise((resolve) => setTimeout(resolve, 10))
1110
+ }
1111
+
1112
+ assert.equal(oneRetained?.connectionCount, 1, "one retired listener source must not clear another source's live connection")
1113
+ assert.equal(oneRetained?.processes.find(({id}) => id === "web")?.state, "running")
1114
+ secondRetainedConnection.write(Buffer.from([0x88, 0x80, 0, 0, 0, 0]))
1115
+ if (intermediate.proxyClosePromise) await intermediate.proxyClosePromise
1116
+ const stopDeadline = Date.now() + 3000
1117
+ let retainedWebStopped = false
1118
+
1119
+ while (!retainedWebStopped && Date.now() < stopDeadline) {
1120
+ const releases = /** @type {{processes: {id: string, state: string}[], releaseId: string}[]} */ ((await sendControlCommand({command: {command: "status"}, path: socketPath})).releases)
1121
+ const retained = releases.find(({releaseId}) => releaseId === "v1")
1122
+
1123
+ retainedWebStopped = !retained || retained.processes.find(({id}) => id === "web")?.state === "stopped"
1124
+ if (!retainedWebStopped) await new Promise((resolve) => setTimeout(resolve, 25))
1125
+ }
1126
+ assert.equal(retainedWebStopped, true, "retained upstream must stop after the incumbent reports its final connection drain")
999
1127
  } finally {
1000
1128
  if (retainedGuardianSocketPath) {
1001
1129
  const cleanupState = JSON.parse(await fs.readFile(statePath, "utf8"))
@@ -1005,10 +1133,17 @@ test("cross-version replacement preserves committed-owner proof until commit the
1005
1133
  }
1006
1134
  const shutdown = replacement?.controlCommandsReady ? replacement.shutdown().catch(() => {}) : owner.shutdown().catch(() => {})
1007
1135
 
1008
- retainedConnection?.destroy()
1136
+ if (retainedConnection && !retainedConnection.destroyed) retainedConnection.write(Buffer.from([0x88, 0x80, 0, 0, 0, 0]))
1137
+ if (secondRetainedConnection && !secondRetainedConnection.destroyed) secondRetainedConnection.write(Buffer.from([0x88, 0x80, 0, 0, 0, 0]))
1009
1138
  await owner.closeServer(owner.proxyServer)
1010
- await Promise.all([fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}), shutdown])
1139
+ await intermediate?.closeServer(intermediate.proxyServer)
1140
+ await Promise.all([
1141
+ fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}),
1142
+ fs.writeFile(path.join(nextReleasePath, "worker.fifo"), "drained\n").catch(() => {}),
1143
+ shutdown
1144
+ ])
1011
1145
  owner.guardian?.disconnect()
1146
+ intermediate?.guardian?.disconnect()
1012
1147
  replacement?.guardian?.disconnect()
1013
1148
  for (const socket of compatibilitySockets) socket.destroy()
1014
1149
  if (compatibilityGuardian?.listening) await closeServer(compatibilityGuardian)
@@ -1090,8 +1225,8 @@ test("a committed replacement crash converges from stale public state", async ()
1090
1225
  await once(candidate, "exit")
1091
1226
  await fs.writeFile(statePath, staleState)
1092
1227
 
1093
- recovered = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
1094
- await waitForLog(recovered, "owner replacement committed")
1228
+ recovered = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
1229
+ await waitForLog(recovered, "control socket listening")
1095
1230
  const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
1096
1231
 
1097
1232
  assert.equal(status.activeReleaseId, "v1")
@@ -1146,6 +1281,250 @@ test("replacement transfers an unchanged fixed proxy listener without reusePort"
1146
1281
  }
1147
1282
  })
1148
1283
 
1284
+ test("direct retired-listener drain updates reach a recovered committed owner", async () => {
1285
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-direct-listener-recovery-"))
1286
+ const socketPath = path.join(root, "rollbridge.sock")
1287
+ const statePath = path.join(root, "state.json")
1288
+ const releasePath = path.join(root, "v1")
1289
+ const proxyPort = await findAvailablePort({host: "127.0.0.1", range: {from: 24000, to: 24999}, usedPorts: new Set()})
1290
+ const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, proxyPort, statePath}))
1291
+ const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
1292
+ let candidate = /** @type {RollbridgeDaemon | undefined} */ (undefined)
1293
+ let recovered = /** @type {RollbridgeDaemon | undefined} */ (undefined)
1294
+ let retainedConnection
1295
+
1296
+ try {
1297
+ await fs.mkdir(releasePath)
1298
+ await makeFifo(path.join(releasePath, "worker.fifo"))
1299
+ await owner.start()
1300
+ await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
1301
+ retainedConnection = await openWebSocket(proxyPort)
1302
+ candidate = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
1303
+ await candidate.replaceIncompatibleOwner()
1304
+ assert.equal(candidate.status().releases[0]?.connectionCount, 1)
1305
+
1306
+ candidate.incumbentListenerControl?.close()
1307
+ await candidate.closeServer(candidate.controlServer)
1308
+ await candidate.closeServer(candidate.proxyServer)
1309
+ candidate.guardian?.disconnect()
1310
+ recovered = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
1311
+ await recovered.start({exposeControl: false})
1312
+ assert.equal(recovered.status().releases[0]?.connectionCount, 1)
1313
+
1314
+ retainedConnection.write(Buffer.from([0x88, 0x80, 0, 0, 0, 0]))
1315
+ if (owner.proxyClosePromise) await owner.proxyClosePromise
1316
+ const deadline = Date.now() + 3000
1317
+
1318
+ while (Date.now() < deadline && recovered.status().releases[0]?.connectionCount !== 0) {
1319
+ await new Promise((resolve) => setTimeout(resolve, 10))
1320
+ }
1321
+ assert.equal(recovered.status().releases[0]?.connectionCount, 0)
1322
+ } finally {
1323
+ retainedConnection?.destroy()
1324
+ await candidate?.closeServer(candidate.controlServer)
1325
+ await candidate?.closeServer(candidate.proxyServer)
1326
+ if (recovered?.guardian) {
1327
+ const shutdown = recovered.shutdown()
1328
+
1329
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)
1330
+ await shutdown.catch(() => undefined)
1331
+ }
1332
+ candidate?.guardian?.disconnect()
1333
+ owner.guardian?.disconnect()
1334
+ await owner.closeServer(owner.proxyServer)
1335
+ await stopGuardian(statePath)
1336
+ await fs.rm(root, {force: true, recursive: true})
1337
+ }
1338
+ })
1339
+
1340
+ test("direct listener publication failure rejects the committed replacement", {timeout: 5000}, async () => {
1341
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-direct-listener-failure-"))
1342
+ const socketPath = path.join(root, "rollbridge.sock")
1343
+ const statePath = path.join(root, "state.json")
1344
+ const releasePath = path.join(root, "v1")
1345
+ const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath}))
1346
+ const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
1347
+ let candidate = /** @type {RollbridgeDaemon | undefined} */ (undefined)
1348
+
1349
+ try {
1350
+ await fs.mkdir(releasePath)
1351
+ await makeFifo(path.join(releasePath, "worker.fifo"))
1352
+ await owner.start()
1353
+ await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
1354
+ owner.publishRetiredConnectionState = async () => { throw new Error("injected retired listener publication failure") }
1355
+ const replacement = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
1356
+
1357
+ candidate = replacement
1358
+ await assert.rejects(
1359
+ () => replacement.replaceIncompatibleOwner(),
1360
+ /Retired listener disconnected before publishing complete connection state/
1361
+ )
1362
+ } finally {
1363
+ await candidate?.closeServer(candidate.controlServer)
1364
+ await candidate?.closeServer(candidate.proxyServer)
1365
+ candidate?.guardian?.disconnect()
1366
+ owner.guardian?.disconnect()
1367
+ await owner.closeServer(owner.controlServer)
1368
+ await owner.closeServer(owner.proxyServer)
1369
+ await stopGuardian(statePath)
1370
+ await fs.rm(root, {force: true, recursive: true})
1371
+ }
1372
+ })
1373
+
1374
+ test("control-less fixed-proxy bind failure preserves incumbent authority", async () => {
1375
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-fixed-proxy-rollback-"))
1376
+ const socketPath = path.join(root, "rollbridge.sock")
1377
+ const statePath = path.join(root, "state.json")
1378
+ const releasePath = path.join(root, "v1")
1379
+ const proxyPort = await findAvailablePort({host: "127.0.0.1", range: {from: 24000, to: 24999}, usedPorts: new Set()})
1380
+ const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, proxyPort, statePath}))
1381
+ const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
1382
+ let candidate = /** @type {RollbridgeDaemon | undefined} */ (undefined)
1383
+ let resumedConnection
1384
+
1385
+ try {
1386
+ await fs.mkdir(releasePath)
1387
+ await makeFifo(path.join(releasePath, "worker.fifo"))
1388
+ await owner.start()
1389
+ await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
1390
+ await owner.closeServer(owner.controlServer)
1391
+ await fs.rm(socketPath, {force: true})
1392
+ const replacement = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
1393
+
1394
+ candidate = replacement
1395
+ replacement.startProxy = async () => { throw new Error("injected fixed proxy bind failure") }
1396
+ await assert.rejects(() => replacement.replaceIncompatibleOwner(), /injected fixed proxy bind failure/)
1397
+ const deadline = Date.now() + 3000
1398
+
1399
+ while (Date.now() < deadline) {
1400
+ try {
1401
+ resumedConnection = net.createConnection({host: "127.0.0.1", port: proxyPort})
1402
+ await once(resumedConnection, "connect")
1403
+ break
1404
+ } catch {
1405
+ await new Promise((resolve) => setTimeout(resolve, 10))
1406
+ }
1407
+ }
1408
+
1409
+ assert.ok(resumedConnection, "incumbent proxy must resume after candidate bind failure")
1410
+ assert.equal(owner.ownerRetired, false)
1411
+ assert.deepEqual(await owner.guardian?.replacementStatus(), {
1412
+ committedReplacementId: null,
1413
+ ownerClaimed: true,
1414
+ retirementFailed: false,
1415
+ retirementPending: false,
1416
+ retirementReady: false
1417
+ })
1418
+ } finally {
1419
+ resumedConnection?.destroy()
1420
+ candidate?.guardian?.disconnect()
1421
+ await owner.closeServer(owner.controlServer)
1422
+ await owner.closeServer(owner.proxyServer)
1423
+ await stopGuardian(statePath)
1424
+ owner.guardian?.disconnect()
1425
+ await fs.rm(root, {force: true, recursive: true})
1426
+ }
1427
+ })
1428
+
1429
+ test("owner recovery finalizes a completed control-less listener handoff after candidate exit", async () => {
1430
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-listener-recovery-"))
1431
+ const socketPath = path.join(root, "rollbridge.sock")
1432
+ const statePath = path.join(root, "state.json")
1433
+ const releasePath = path.join(root, "v1")
1434
+ const proxyPort = await findAvailablePort({host: "127.0.0.1", range: {from: 24000, to: 24999}, usedPorts: new Set()})
1435
+ const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, proxyPort, statePath}))
1436
+ const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
1437
+ let candidate = /** @type {RollbridgeDaemon | undefined} */ (undefined)
1438
+ let recovered = /** @type {RollbridgeDaemon | undefined} */ (undefined)
1439
+
1440
+ try {
1441
+ await fs.mkdir(releasePath)
1442
+ await makeFifo(path.join(releasePath, "worker.fifo"))
1443
+ await owner.start()
1444
+ await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
1445
+ await owner.closeServer(owner.controlServer)
1446
+ await fs.rm(socketPath, {force: true})
1447
+ const replacement = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
1448
+ const startProxy = replacement.startProxy.bind(replacement)
1449
+
1450
+ candidate = replacement
1451
+ replacement.startProxy = async () => {
1452
+ await startProxy()
1453
+ const guardian = replacement.guardian
1454
+
1455
+ if (!guardian) throw new Error("Recovery fixture candidate is missing its guardian")
1456
+ guardian.finalizeOwnerReplacement = async () => {
1457
+ guardian.disconnect()
1458
+ throw new Error("injected candidate exit before listener finalization")
1459
+ }
1460
+ }
1461
+ await assert.rejects(() => replacement.replaceIncompatibleOwner(), /injected candidate exit before listener finalization/)
1462
+ await replacement.closeServer(replacement.controlServer)
1463
+ await replacement.closeServer(replacement.proxyServer)
1464
+
1465
+ recovered = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
1466
+ await recovered.start({exposeControl: false})
1467
+
1468
+ assert.equal(recovered.getProxyPort(), proxyPort)
1469
+ assert.equal((await recovered.guardian?.replacementStatus())?.retirementPending, false)
1470
+ } finally {
1471
+ await candidate?.closeServer(candidate.controlServer)
1472
+ await candidate?.closeServer(candidate.proxyServer)
1473
+ if (recovered?.guardian) {
1474
+ const shutdown = recovered.shutdown()
1475
+
1476
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)
1477
+ await shutdown.catch(() => undefined)
1478
+ }
1479
+ candidate?.guardian?.disconnect()
1480
+ owner.guardian?.disconnect()
1481
+ await owner.closeServer(owner.proxyServer)
1482
+ await stopGuardian(statePath)
1483
+ await fs.rm(root, {force: true, recursive: true})
1484
+ }
1485
+ })
1486
+
1487
+ test("control-less retirement clears an active source but omits a stopped release without local connections", async () => {
1488
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-stopped-source-"))
1489
+ const statePath = path.join(root, "state.json")
1490
+ const daemon = new RollbridgeDaemon({
1491
+ config: normalizeConfig(config({controlPath: path.join(root, "rollbridge.sock"), extraCompanion: false, statePath})),
1492
+ logger: () => {}
1493
+ })
1494
+ const published = /** @type {{releaseId: string, sourceId: string}[]} */ ([])
1495
+ let disconnected = false
1496
+ const guardian = new GuardianClient({socketPath: path.join(root, "guardian.sock"), token: "test-token"})
1497
+
1498
+ guardian.completeOwnerListenerRetirement = async () => {}
1499
+ guardian.disconnect = () => { disconnected = true }
1500
+ guardian.publishOwnerConnectionState = async (_replacementId, sourceId, releaseId) => { published.push({releaseId, sourceId}) }
1501
+ guardian.waitForEvent = async () => ({event: "replacement-retired"})
1502
+ daemon.guardian = guardian
1503
+ daemon.releases.set("stopped", /** @type {import("../src/release-group.js").default} */ ({
1504
+ localConnections: () => ({http: 0, websocket: 0}),
1505
+ releaseId: "stopped",
1506
+ state: "stopped",
1507
+ status: () => ({connectionCount: 0})
1508
+ }))
1509
+ daemon.releases.set("active", /** @type {import("../src/release-group.js").default} */ ({
1510
+ localConnections: () => ({http: 0, websocket: 0}),
1511
+ releaseId: "active",
1512
+ state: "active",
1513
+ status: () => ({connectionCount: 0})
1514
+ }))
1515
+ daemon.retireCommittedOwner = async () => { daemon.ownerRetired = true }
1516
+
1517
+ try {
1518
+ await daemon.yieldControlLessOwnerListeners("replacement")
1519
+ await daemon.completeControlLessOwnerRetirement("replacement")
1520
+
1521
+ assert.deepEqual(published, [{releaseId: "active", sourceId: daemon.listenerSourceId}])
1522
+ assert.equal(disconnected, true)
1523
+ } finally {
1524
+ await fs.rm(root, {force: true, recursive: true})
1525
+ }
1526
+ })
1527
+
1149
1528
  test("owner replacement preserves committed generation metadata without firing lifecycle hooks", async () => {
1150
1529
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-generation-"))
1151
1530
  const oldSocketPath = path.join(root, "old.sock")
@@ -1270,10 +1649,9 @@ test("pruned release connection completion closes the incumbent listener session
1270
1649
  assert.equal(daemon.incumbentListenerControl, session)
1271
1650
  })
1272
1651
 
1273
- test("owner replacement preserves a failed generation transition without retrying its hook", async () => {
1652
+ test("same-authority owner replacement preserves completed activation compensation without replaying hooks", async () => {
1274
1653
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-failed-generation-"))
1275
1654
  const oldSocketPath = path.join(root, "old.sock")
1276
- const newSocketPath = path.join(root, "new.sock")
1277
1655
  const statePath = path.join(root, "state.json")
1278
1656
  const configPath = path.join(root, "rollbridge.cjs")
1279
1657
  const lifecycleLogPath = path.join(root, "generation.lifecycle")
@@ -1299,20 +1677,68 @@ test("owner replacement preserves a failed generation transition without retryin
1299
1677
  await waitForLog(owner, "control socket listening")
1300
1678
  await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
1301
1679
  await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath}), /activate command exited non-zero/)
1302
- assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n")
1680
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\nactivate:v1\n")
1303
1681
 
1304
- await writeConfig(configPath, failedConfig(newSocketPath, true))
1682
+ await writeConfig(configPath, failedConfig(oldSocketPath, false))
1305
1683
  candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
1306
1684
  await waitForLog(candidate, "owner replacement committed")
1307
- const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
1308
- const generationTransition = status.generationTransition
1685
+ const status = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
1686
+ assert.equal(status.activeReleaseId, "v1")
1687
+ assert.equal(status.generationTransition, undefined)
1688
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\nactivate:v1\n", "replacement must not replay completed compensation hooks")
1309
1689
 
1310
- assert.ok(generationTransition && typeof generationTransition === "object" && !Array.isArray(generationTransition))
1311
- assert.equal(generationTransition.phase, "activating_candidate")
1312
- assert.match(String(generationTransition.error), /activate command exited non-zero/)
1313
- assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n", "replacement must preserve, not retry, the failed activation")
1690
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
1691
+ await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
1692
+ await shutdown
1693
+ } finally {
1694
+ for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
1695
+ await stopGuardian(statePath)
1696
+ await fs.rm(root, {force: true, recursive: true})
1697
+ }
1698
+ })
1314
1699
 
1700
+ test("config-changing owner replacement proceeds after activation compensation clears the transition", async () => {
1701
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-unresolved-config-"))
1702
+ const oldSocketPath = path.join(root, "old.sock")
1703
+ const newSocketPath = path.join(root, "new.sock")
1704
+ const statePath = path.join(root, "state.json")
1705
+ const configPath = path.join(root, "rollbridge.cjs")
1706
+ const lifecycleLogPath = path.join(root, "generation.lifecycle")
1707
+ const v1Path = path.join(root, "v1")
1708
+ const v2Path = path.join(root, "v2")
1709
+ let owner
1710
+ let candidate
1711
+ const failedConfig = (/** @type {string} */ controlPath, /** @type {boolean} */ extraCompanion) => {
1712
+ const raw = config({activationLogPath: lifecycleLogPath, controlPath, extraCompanion, statePath})
1713
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (raw.processes)
1714
+ const generationMain = processes.find((processConfig) => processConfig.id === "generation-main")
1715
+ const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (generationMain?.lifecycle)
1716
+
1717
+ lifecycle.activateCommand = `[ "$ROLLBRIDGE_RELEASE_ID" != v2 ] || exit 24; printf 'activate:%s\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1718
+ return raw
1719
+ }
1720
+
1721
+ try {
1722
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
1723
+ await Promise.all([makeFifo(path.join(v1Path, "worker.fifo")), makeFifo(path.join(v2Path, "worker.fifo"))])
1724
+ await writeConfig(configPath, failedConfig(oldSocketPath, false))
1725
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
1726
+ await waitForLog(owner, "control socket listening")
1727
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
1728
+ await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath}), /activate command exited non-zero/)
1729
+ await writeConfig(configPath, failedConfig(newSocketPath, true))
1730
+
1731
+ candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
1732
+ const result = await collectUntilExitOrLog(candidate, "owner replacement committed")
1733
+
1734
+ assert.equal(result.message, "owner replacement committed", result.output)
1735
+ const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
1736
+
1737
+ assert.equal(status.activeReleaseId, "v1")
1738
+ assert.equal(status.generationTransition, undefined)
1739
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\nactivate:v1\n")
1315
1740
  const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
1741
+
1316
1742
  await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
1317
1743
  await shutdown
1318
1744
  } finally {
@@ -1486,6 +1912,39 @@ async function prepareCandidatePackage(destination, options = {}) {
1486
1912
  }
1487
1913
  }
1488
1914
 
1915
+ /**
1916
+ * Models the immediately preceding guardian protocol: atomic replacement exists,
1917
+ * but the daemon-recovery capability probe does not.
1918
+ * @param {string} packagePath - Copied package root.
1919
+ * @param {{abortedPath: string, preparedPath: string}} markers - Incumbent event-processing markers.
1920
+ */
1921
+ async function removeDaemonRecoveryCapability(packagePath, {abortedPath, preparedPath}) {
1922
+ const guardianPath = path.join(packagePath, "src", "process-guardian.js")
1923
+ const daemonPath = path.join(packagePath, "src", "daemon.js")
1924
+ const source = await fs.readFile(guardianPath, "utf8")
1925
+ const capability = " if (request.command === \"capabilities\") return {daemonRecovery: 1, generationReactivation: 1}\n\n"
1926
+ const incumbentAbortNotification = " if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: \"replacement-aborted\", reason})}\\n`)\n"
1927
+ const legacyCapability = ` if (request.command === "capabilities") {
1928
+ while (!fsSync.existsSync(${JSON.stringify(preparedPath)})) await new Promise((resolve) => setTimeout(resolve, 5))
1929
+ throw new Error("Guardian capabilities requires a process key")
1930
+ }
1931
+
1932
+ `
1933
+
1934
+ assert.ok(source.includes(capability))
1935
+ assert.ok(source.includes(incumbentAbortNotification))
1936
+ await fs.writeFile(guardianPath, source.replace(capability, legacyCapability))
1937
+ const daemonSource = await fs.readFile(daemonPath, "utf8")
1938
+ const preparedHandler = " this.guardian.onEvent(\"replacement-prepared\", () => {\n for (const release of this.releases.values()) release.pauseDrainForOwnerHandoff()\n"
1939
+ const abortedHandler = " this.guardian.onEvent(\"replacement-aborted\", () => {\n"
1940
+
1941
+ assert.ok(daemonSource.includes(preparedHandler))
1942
+ assert.ok(daemonSource.includes(abortedHandler))
1943
+ await fs.writeFile(daemonPath, daemonSource
1944
+ .replace(preparedHandler, `${preparedHandler} void fs.writeFile(${JSON.stringify(preparedPath)}, "paused\\n")\n`)
1945
+ .replace(abortedHandler, `${abortedHandler} void fs.writeFile(${JSON.stringify(abortedPath)}, "aborted\\n")\n`))
1946
+ }
1947
+
1489
1948
  /**
1490
1949
  * Replaces only the copied package's daemon entry with an exact early-exit fixture.
1491
1950
  * @param {string} packagePath - Copied candidate package root.
@@ -1497,9 +1956,12 @@ async function installCandidateExit(packagePath, evidencePath, exitCode) {
1497
1956
  const binPath = path.join(packagePath, "bin", "rollbridge")
1498
1957
  const source = `#!/usr/bin/env node
1499
1958
  import fs from "node:fs"
1959
+ import {spawn} from "node:child_process"
1500
1960
 
1501
1961
  if (process.argv[2] === "daemon") {
1502
- fs.writeFileSync(${JSON.stringify(evidencePath)}, JSON.stringify({argv: process.argv, pid: process.pid, ppid: process.ppid}))
1962
+ const descendant = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"})
1963
+ fs.writeFileSync(${JSON.stringify(evidencePath)}, JSON.stringify({argv: process.argv, descendantPid: descendant.pid, pid: process.pid, ppid: process.ppid}))
1964
+ descendant.unref()
1503
1965
  process.exit(${exitCode})
1504
1966
  }
1505
1967
 
@@ -1591,6 +2053,41 @@ function releaseProcessPid(status, releaseId, processId) {
1591
2053
  return pid
1592
2054
  }
1593
2055
 
2056
+ /**
2057
+ * Waits until the incumbent resumes and finishes a previously paused drain.
2058
+ * @param {string} socketPath - Incumbent control socket.
2059
+ * @param {string} releaseId - Draining release.
2060
+ * @param {string} expectedState - Expected completed state.
2061
+ */
2062
+ async function waitForReleaseState(socketPath, releaseId, expectedState) {
2063
+ const deadline = Date.now() + 10000
2064
+
2065
+ while (Date.now() < deadline) {
2066
+ const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
2067
+ const releases = /** @type {{releaseId: string, state: string}[]} */ (status.releases)
2068
+
2069
+ if (releases.find((release) => release.releaseId === releaseId)?.state === expectedState) return
2070
+ await new Promise((resolve) => setTimeout(resolve, 25))
2071
+ }
2072
+ throw new Error(`Timed out waiting for release ${releaseId} to reach ${expectedState}`)
2073
+ }
2074
+
2075
+ /** @param {string} filePath - Exact fixture marker. */
2076
+ async function waitForFile(filePath) {
2077
+ const deadline = Date.now() + 3000
2078
+
2079
+ while (Date.now() < deadline) {
2080
+ try {
2081
+ await fs.access(filePath)
2082
+ return
2083
+ } catch (error) {
2084
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
2085
+ }
2086
+ await new Promise((resolve) => setTimeout(resolve, 25))
2087
+ }
2088
+ throw new Error(`Timed out waiting for ${filePath}`)
2089
+ }
2090
+
1594
2091
  /**
1595
2092
  * @param {Record<string, import("../src/json.js").JsonValue>} status - Daemon status.
1596
2093
  * @param {string} releaseId - Release identity.