rollbridge 0.1.39 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -4
- package/changelog.d/20260830-guardian-retired-replacement-process-key.md +12 -1
- package/changelog.d/20260830-release-generation-activation-lifecycle.md +16 -2
- package/changelog.d/20260830055159-guardian-daemon-restart.md +2 -0
- package/docs/cli.md +23 -9
- package/docs/config.md +37 -13
- package/docs/logging.md +4 -3
- package/docs/troubleshooting.md +7 -5
- package/package.json +1 -1
- package/src/cli.js +93 -24
- package/src/config.js +14 -6
- package/src/daemon.js +643 -150
- package/src/guardian-client.js +68 -16
- package/src/managed-process.js +55 -8
- package/src/process-guardian.js +731 -43
- package/src/release-group.js +23 -7
- package/test/config-validation.test.js +4 -0
- package/test/fixtures/guardian-recovery-owner.js +86 -0
- package/test/fixtures/pre-split3-process-guardian.js +14 -0
- package/test/guardian-client.test.js +1339 -62
- package/test/managed-process.test.js +136 -7
- package/test/owner-recovery.test.js +564 -55
- package/test/owner-replacement.test.js +526 -23
- package/test/release-group.test.js +19 -2
- package/test/release-runtime-retention.test.js +121 -4
- package/test/rollbridge.test.js +21 -0
- package/test/support/process.js +41 -0
|
@@ -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.
|
|
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
|
-
|
|
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), [
|
|
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 (
|
|
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
|
|
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
|
|
978
|
+
const runningProxyPort = /** @type {{port?: number}} */ (running.proxy).port
|
|
906
979
|
|
|
907
980
|
assert.deepEqual(processState?.map(({state}) => state), ["running", "running"])
|
|
908
|
-
if (typeof
|
|
909
|
-
retainedConnection = await openWebSocket(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1094
|
-
await waitForLog(recovered, "
|
|
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 a failed generation transition without retrying its hook", 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")
|
|
@@ -1301,10 +1679,10 @@ test("owner replacement preserves a failed generation transition without retryin
|
|
|
1301
1679
|
await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath}), /activate command exited non-zero/)
|
|
1302
1680
|
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n")
|
|
1303
1681
|
|
|
1304
|
-
await writeConfig(configPath, failedConfig(
|
|
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:
|
|
1685
|
+
const status = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
|
|
1308
1686
|
const generationTransition = status.generationTransition
|
|
1309
1687
|
|
|
1310
1688
|
assert.ok(generationTransition && typeof generationTransition === "object" && !Array.isArray(generationTransition))
|
|
@@ -1312,7 +1690,61 @@ test("owner replacement preserves a failed generation transition without retryin
|
|
|
1312
1690
|
assert.match(String(generationTransition.error), /activate command exited non-zero/)
|
|
1313
1691
|
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n", "replacement must preserve, not retry, the failed activation")
|
|
1314
1692
|
|
|
1315
|
-
const shutdown = sendControlCommand({command: {command: "shutdown"}, path:
|
|
1693
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
|
|
1694
|
+
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
1695
|
+
await shutdown
|
|
1696
|
+
} finally {
|
|
1697
|
+
for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
1698
|
+
await stopGuardian(statePath)
|
|
1699
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
1700
|
+
}
|
|
1701
|
+
})
|
|
1702
|
+
|
|
1703
|
+
test("config-changing owner replacement rejects an unresolved generation transition", async () => {
|
|
1704
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-unresolved-config-"))
|
|
1705
|
+
const oldSocketPath = path.join(root, "old.sock")
|
|
1706
|
+
const newSocketPath = path.join(root, "new.sock")
|
|
1707
|
+
const statePath = path.join(root, "state.json")
|
|
1708
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
1709
|
+
const lifecycleLogPath = path.join(root, "generation.lifecycle")
|
|
1710
|
+
const v1Path = path.join(root, "v1")
|
|
1711
|
+
const v2Path = path.join(root, "v2")
|
|
1712
|
+
let owner
|
|
1713
|
+
let candidate
|
|
1714
|
+
const failedConfig = (/** @type {string} */ controlPath, /** @type {boolean} */ extraCompanion) => {
|
|
1715
|
+
const raw = config({activationLogPath: lifecycleLogPath, controlPath, extraCompanion, statePath})
|
|
1716
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (raw.processes)
|
|
1717
|
+
const generationMain = processes.find((processConfig) => processConfig.id === "generation-main")
|
|
1718
|
+
const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (generationMain?.lifecycle)
|
|
1719
|
+
|
|
1720
|
+
lifecycle.activateCommand = `[ "$ROLLBRIDGE_RELEASE_ID" != v2 ] || exit 24; printf 'activate:%s\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
|
|
1721
|
+
return raw
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
try {
|
|
1725
|
+
await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
|
|
1726
|
+
await Promise.all([makeFifo(path.join(v1Path, "worker.fifo")), makeFifo(path.join(v2Path, "worker.fifo"))])
|
|
1727
|
+
await writeConfig(configPath, failedConfig(oldSocketPath, false))
|
|
1728
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1729
|
+
await waitForLog(owner, "control socket listening")
|
|
1730
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
|
|
1731
|
+
await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath}), /activate command exited non-zero/)
|
|
1732
|
+
await writeConfig(configPath, failedConfig(newSocketPath, true))
|
|
1733
|
+
|
|
1734
|
+
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1735
|
+
const result = await collectUntilExitOrLog(candidate, "owner replacement committed")
|
|
1736
|
+
|
|
1737
|
+
assert.equal(result.message, undefined, result.output)
|
|
1738
|
+
assert.match(result.output, /config authority.*unresolved generation transition/i)
|
|
1739
|
+
const status = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
|
|
1740
|
+
const generationTransition = status.generationTransition
|
|
1741
|
+
|
|
1742
|
+
assert.ok(generationTransition && typeof generationTransition === "object" && !Array.isArray(generationTransition))
|
|
1743
|
+
assert.equal(generationTransition.phase, "activating_candidate")
|
|
1744
|
+
assert.match(String(generationTransition.error), /activate command exited non-zero/)
|
|
1745
|
+
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n")
|
|
1746
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
|
|
1747
|
+
|
|
1316
1748
|
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
1317
1749
|
await shutdown
|
|
1318
1750
|
} finally {
|
|
@@ -1486,6 +1918,39 @@ async function prepareCandidatePackage(destination, options = {}) {
|
|
|
1486
1918
|
}
|
|
1487
1919
|
}
|
|
1488
1920
|
|
|
1921
|
+
/**
|
|
1922
|
+
* Models the immediately preceding guardian protocol: atomic replacement exists,
|
|
1923
|
+
* but the daemon-recovery capability probe does not.
|
|
1924
|
+
* @param {string} packagePath - Copied package root.
|
|
1925
|
+
* @param {{abortedPath: string, preparedPath: string}} markers - Incumbent event-processing markers.
|
|
1926
|
+
*/
|
|
1927
|
+
async function removeDaemonRecoveryCapability(packagePath, {abortedPath, preparedPath}) {
|
|
1928
|
+
const guardianPath = path.join(packagePath, "src", "process-guardian.js")
|
|
1929
|
+
const daemonPath = path.join(packagePath, "src", "daemon.js")
|
|
1930
|
+
const source = await fs.readFile(guardianPath, "utf8")
|
|
1931
|
+
const capability = " if (request.command === \"capabilities\") return {daemonRecovery: 1}\n\n"
|
|
1932
|
+
const incumbentAbortNotification = " if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: \"replacement-aborted\", reason})}\\n`)\n"
|
|
1933
|
+
const legacyCapability = ` if (request.command === "capabilities") {
|
|
1934
|
+
while (!fsSync.existsSync(${JSON.stringify(preparedPath)})) await new Promise((resolve) => setTimeout(resolve, 5))
|
|
1935
|
+
throw new Error("Guardian capabilities requires a process key")
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
`
|
|
1939
|
+
|
|
1940
|
+
assert.ok(source.includes(capability))
|
|
1941
|
+
assert.ok(source.includes(incumbentAbortNotification))
|
|
1942
|
+
await fs.writeFile(guardianPath, source.replace(capability, legacyCapability))
|
|
1943
|
+
const daemonSource = await fs.readFile(daemonPath, "utf8")
|
|
1944
|
+
const preparedHandler = " this.guardian.onEvent(\"replacement-prepared\", () => {\n for (const release of this.releases.values()) release.pauseDrainForOwnerHandoff()\n"
|
|
1945
|
+
const abortedHandler = " this.guardian.onEvent(\"replacement-aborted\", () => {\n"
|
|
1946
|
+
|
|
1947
|
+
assert.ok(daemonSource.includes(preparedHandler))
|
|
1948
|
+
assert.ok(daemonSource.includes(abortedHandler))
|
|
1949
|
+
await fs.writeFile(daemonPath, daemonSource
|
|
1950
|
+
.replace(preparedHandler, `${preparedHandler} void fs.writeFile(${JSON.stringify(preparedPath)}, "paused\\n")\n`)
|
|
1951
|
+
.replace(abortedHandler, `${abortedHandler} void fs.writeFile(${JSON.stringify(abortedPath)}, "aborted\\n")\n`))
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1489
1954
|
/**
|
|
1490
1955
|
* Replaces only the copied package's daemon entry with an exact early-exit fixture.
|
|
1491
1956
|
* @param {string} packagePath - Copied candidate package root.
|
|
@@ -1497,9 +1962,12 @@ async function installCandidateExit(packagePath, evidencePath, exitCode) {
|
|
|
1497
1962
|
const binPath = path.join(packagePath, "bin", "rollbridge")
|
|
1498
1963
|
const source = `#!/usr/bin/env node
|
|
1499
1964
|
import fs from "node:fs"
|
|
1965
|
+
import {spawn} from "node:child_process"
|
|
1500
1966
|
|
|
1501
1967
|
if (process.argv[2] === "daemon") {
|
|
1502
|
-
|
|
1968
|
+
const descendant = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"})
|
|
1969
|
+
fs.writeFileSync(${JSON.stringify(evidencePath)}, JSON.stringify({argv: process.argv, descendantPid: descendant.pid, pid: process.pid, ppid: process.ppid}))
|
|
1970
|
+
descendant.unref()
|
|
1503
1971
|
process.exit(${exitCode})
|
|
1504
1972
|
}
|
|
1505
1973
|
|
|
@@ -1591,6 +2059,41 @@ function releaseProcessPid(status, releaseId, processId) {
|
|
|
1591
2059
|
return pid
|
|
1592
2060
|
}
|
|
1593
2061
|
|
|
2062
|
+
/**
|
|
2063
|
+
* Waits until the incumbent resumes and finishes a previously paused drain.
|
|
2064
|
+
* @param {string} socketPath - Incumbent control socket.
|
|
2065
|
+
* @param {string} releaseId - Draining release.
|
|
2066
|
+
* @param {string} expectedState - Expected completed state.
|
|
2067
|
+
*/
|
|
2068
|
+
async function waitForReleaseState(socketPath, releaseId, expectedState) {
|
|
2069
|
+
const deadline = Date.now() + 10000
|
|
2070
|
+
|
|
2071
|
+
while (Date.now() < deadline) {
|
|
2072
|
+
const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
2073
|
+
const releases = /** @type {{releaseId: string, state: string}[]} */ (status.releases)
|
|
2074
|
+
|
|
2075
|
+
if (releases.find((release) => release.releaseId === releaseId)?.state === expectedState) return
|
|
2076
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
2077
|
+
}
|
|
2078
|
+
throw new Error(`Timed out waiting for release ${releaseId} to reach ${expectedState}`)
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
/** @param {string} filePath - Exact fixture marker. */
|
|
2082
|
+
async function waitForFile(filePath) {
|
|
2083
|
+
const deadline = Date.now() + 3000
|
|
2084
|
+
|
|
2085
|
+
while (Date.now() < deadline) {
|
|
2086
|
+
try {
|
|
2087
|
+
await fs.access(filePath)
|
|
2088
|
+
return
|
|
2089
|
+
} catch (error) {
|
|
2090
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
|
|
2091
|
+
}
|
|
2092
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
2093
|
+
}
|
|
2094
|
+
throw new Error(`Timed out waiting for ${filePath}`)
|
|
2095
|
+
}
|
|
2096
|
+
|
|
1594
2097
|
/**
|
|
1595
2098
|
* @param {Record<string, import("../src/json.js").JsonValue>} status - Daemon status.
|
|
1596
2099
|
* @param {string} releaseId - Release identity.
|