rollbridge 0.1.37 → 0.1.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/daemon.js CHANGED
@@ -340,7 +340,7 @@ export default class RollbridgeDaemon {
340
340
  if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
341
341
  const registeredProcesses = new Map((await this.guardian.inventory()).map(({key, provenance}) => [key, provenance]))
342
342
 
343
- reservedProcessKey = legacyBridge ? undefined : ownerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
343
+ reservedProcessKey = legacyBridge ? undefined : reconstructableOwnerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
344
344
  .find((key) => registeredProcesses.has(key))
345
345
  if (reservedProcessKey) this.guardian.reserveProcessRecovery(reservedProcessKey, /** @type {string} */ (registeredProcesses.get(reservedProcessKey)))
346
346
  await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false, singletonReleaseIds: transfer.singletonReleaseIds, synchronizeLifecycleRoles: false})
@@ -547,13 +547,14 @@ export default class RollbridgeDaemon {
547
547
  if (!releaseId || !connections || typeof connections !== "object" || Array.isArray(connections)) {
548
548
  throw new Error("Incumbent listener sent invalid connection state")
549
549
  }
550
- const release = this.releases.get(releaseId)
551
-
552
- if (!release) throw new Error(`Incumbent listener reported unknown release ${releaseId}`)
553
- release.setTransferredConnections({
550
+ const transferredConnections = {
554
551
  http: requiredNonNegativeInteger(connections.http, "connections.http"),
555
552
  websocket: requiredNonNegativeInteger(connections.websocket, "connections.websocket")
556
- })
553
+ }
554
+ const release = this.releases.get(releaseId)
555
+
556
+ if (!release && (transferredConnections.http > 0 || transferredConnections.websocket > 0)) throw new Error(`Incumbent listener reported unknown release ${releaseId}`)
557
+ if (release) release.setTransferredConnections(transferredConnections)
557
558
  if (this.incumbentListenerControl === session && ![...this.releases.values()].some((candidate) => candidate.hasTransferredConnections())) {
558
559
  this.incumbentListenerControl = undefined
559
560
  session.close()
@@ -2121,6 +2122,27 @@ function ownerSnapshotProcessKeys(snapshot, singletonReleaseIds = {}) {
2121
2122
  return keys
2122
2123
  }
2123
2124
 
2125
+ /**
2126
+ * Selects only committed guardian registrations that restoreOwnerState will reconstruct.
2127
+ * @param {OwnerRecoverySnapshot} snapshot - Serialized owner process snapshot.
2128
+ * @param {Record<string, string>} [singletonReleaseIds] - Exact singleton owner releases.
2129
+ * @returns {string[]} Exact reconstructable guardian registration keys.
2130
+ */
2131
+ function reconstructableOwnerSnapshotProcessKeys(snapshot, singletonReleaseIds = {}) {
2132
+ const singletonOwnerReleaseIds = new Set(Object.values(singletonReleaseIds))
2133
+ const releaseProcessKeys = new Set()
2134
+
2135
+ for (const release of snapshot.releases) {
2136
+ const transitionCandidate = snapshot.generationTransition?.candidateReleaseId === release.releaseId && snapshot.generationTransition.phase !== "committed"
2137
+ const singletonOwner = singletonOwnerReleaseIds.has(release.releaseId)
2138
+
2139
+ if (release.state !== "active" && release.state !== "draining" && !transitionCandidate && !singletonOwner) continue
2140
+ for (const processStatus of release.processes) releaseProcessKeys.add(`release:${release.releaseId}:${processStatus.id}`)
2141
+ }
2142
+ return ownerSnapshotProcessKeys(snapshot, singletonReleaseIds)
2143
+ .filter((key) => !key.startsWith("release:") || releaseProcessKeys.has(key))
2144
+ }
2145
+
2124
2146
  /**
2125
2147
  * Canonicalizes the authenticated guardian inventory without relying on map insertion order.
2126
2148
  * @param {{key: string, provenance: string}[]} inventory - Guardian inventory response.
@@ -10,7 +10,7 @@ import path from "node:path"
10
10
  import test from "node:test"
11
11
  import {fileURLToPath} from "node:url"
12
12
  import {normalizeConfig} from "../src/config.js"
13
- import {sendControlCommand} from "../src/control-client.js"
13
+ 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"
@@ -1188,6 +1188,88 @@ test("owner replacement preserves committed generation metadata without firing l
1188
1188
  }
1189
1189
  })
1190
1190
 
1191
+ test("owner replacement excludes stopped retained releases from reserved process recovery", async () => {
1192
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-stopped-proof-"))
1193
+ const oldSocketPath = path.join(root, "old.sock")
1194
+ const newSocketPath = path.join(root, "new.sock")
1195
+ const statePath = path.join(root, "state.json")
1196
+ const configPath = path.join(root, "rollbridge.cjs")
1197
+ const v1Path = path.join(root, "v1")
1198
+ const v2Path = path.join(root, "v2")
1199
+ let owner
1200
+ let candidate
1201
+
1202
+ try {
1203
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
1204
+ await Promise.all([makeFifo(path.join(v1Path, "worker.fifo")), makeFifo(path.join(v2Path, "worker.fifo"))])
1205
+ await writeConfig(configPath, config({controlPath: oldSocketPath, extraCompanion: false, statePath}))
1206
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
1207
+ await waitForLog(owner, "control socket listening")
1208
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
1209
+ const stopped = sendControlCommand({command: {command: "stop", releaseId: "v1"}, path: oldSocketPath})
1210
+
1211
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
1212
+ await stopped
1213
+ await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath})
1214
+ const before = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
1215
+ const activeWorkerPid = releaseProcessPid(before, "v2", "worker")
1216
+ const retained = /** @type {{releaseId: string, state: string}[]} */ (before.releases)
1217
+
1218
+ assert.equal(retained.find(({releaseId}) => releaseId === "v1")?.state, "stopped")
1219
+ const persisted = JSON.parse(await fs.readFile(statePath, "utf8"))
1220
+ const guardian = new GuardianClient(persisted.recovery.guardian)
1221
+
1222
+ await guardian.connect()
1223
+ assert.ok((await guardian.inventory()).some(({key}) => key === "release:v1:worker"), "stopped release registration remains in authenticated guardian inventory")
1224
+ guardian.disconnect()
1225
+ await writeConfig(configPath, config({controlPath: newSocketPath, extraCompanion: true, statePath}))
1226
+ candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
1227
+ const output = await collectUntilExitOrLog(candidate, "owner replacement committed")
1228
+
1229
+ assert.equal(output.message, "owner replacement committed", output.output)
1230
+ const recovered = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
1231
+
1232
+ assert.equal(recovered.activeReleaseId, "v2")
1233
+ assert.equal(releaseProcessPid(recovered, "v2", "worker"), activeWorkerPid)
1234
+ assert.equal(/** @type {{releaseId: string}[]} */ (recovered.releases).some(({releaseId}) => releaseId === "v1"), false)
1235
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
1236
+
1237
+ await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
1238
+ await shutdown
1239
+ } finally {
1240
+ for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
1241
+ await stopGuardian(statePath)
1242
+ await fs.rm(root, {force: true, recursive: true})
1243
+ }
1244
+ })
1245
+
1246
+ test("pruned release connection completion closes the incumbent listener session", () => {
1247
+ const daemon = new RollbridgeDaemon({
1248
+ config: normalizeConfig(config({controlPath: "/unused/control.sock", extraCompanion: false, statePath: "/unused/state.json"})),
1249
+ logger: () => {}
1250
+ })
1251
+ let closeCount = 0
1252
+ const session = {close: () => { closeCount += 1 }}
1253
+ const controlSession = /** @type {Awaited<ReturnType<typeof openControlSession>>} */ (session)
1254
+
1255
+ daemon.releases = /** @type {Map<string, import("../src/release-group.js").default>} */ (new Map([
1256
+ ["active", /** @type {import("../src/release-group.js").default} */ ({hasTransferredConnections: () => false})]
1257
+ ]))
1258
+ daemon.incumbentListenerControl = controlSession
1259
+ daemon.handleIncumbentListenerEvent({connections: {http: 0, websocket: 0}, event: "owner-connection-state", releaseId: "pruned"}, session)
1260
+
1261
+ assert.equal(closeCount, 1)
1262
+ assert.equal(daemon.incumbentListenerControl, undefined)
1263
+
1264
+ daemon.incumbentListenerControl = controlSession
1265
+ assert.throws(
1266
+ () => daemon.handleIncumbentListenerEvent({connections: {http: 1, websocket: 0}, event: "owner-connection-state", releaseId: "pruned"}, session),
1267
+ /unknown release pruned/
1268
+ )
1269
+ assert.equal(closeCount, 1)
1270
+ assert.equal(daemon.incumbentListenerControl, session)
1271
+ })
1272
+
1191
1273
  test("owner replacement preserves a failed generation transition without retrying its hook", async () => {
1192
1274
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-failed-generation-"))
1193
1275
  const oldSocketPath = path.join(root, "old.sock")