rollbridge 0.1.32 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ ### Fixed
2
+
3
+ - Carry an exact recovered guardian process key when a ready replacement commits
4
+ after the incumbent control listener has already retired, while retaining the
5
+ replacement transaction, authority, and registered-process fences.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
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
@@ -412,7 +412,10 @@ export default class RollbridgeDaemon {
412
412
 
413
413
  if (!staged.committed) {
414
414
  if (retiredIncumbentControl) {
415
- await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId)
415
+ const processKey = this.guardian.processes.keys().next().value
416
+
417
+ if (!processKey) throw new Error("Retired owner replacement requires an exact recovered guardian process registration")
418
+ await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId, processKey)
416
419
  } else {
417
420
  try {
418
421
  if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
@@ -194,9 +194,12 @@ export default class GuardianClient {
194
194
  await this.request({command: "commit-owner-replacement", replacementId})
195
195
  }
196
196
 
197
- /** @param {string} replacementId - Same-authority transaction whose incumbent listener is absent. */
198
- async commitRetiredOwnerReplacement(replacementId) {
199
- await this.request({command: "commit-retired-owner-replacement", replacementId})
197
+ /**
198
+ * @param {string} replacementId - Same-authority transaction whose incumbent listener is absent.
199
+ * @param {string} key - Exact recovered guardian process proving candidate reconstruction.
200
+ */
201
+ async commitRetiredOwnerReplacement(replacementId, key) {
202
+ await this.request({command: "commit-retired-owner-replacement", key, replacementId})
200
203
  }
201
204
 
202
205
  /** @param {string} replacementId - Committed transaction awaiting incumbent retirement. */
@@ -300,6 +300,7 @@ async function execute(request, socket) {
300
300
 
301
301
  if (request.command === "commit-retired-owner-replacement") {
302
302
  requireReplacement(socket, request)
303
+ requireProcess(request)
303
304
  if (!replacementOwnerState) throw new Error("Retired owner replacement transaction is not staged")
304
305
  if (!isDeepStrictEqual(ownerAuthority(ownerState), replacementAuthority)) throw new Error("Retired owner replacement requires unchanged owner authority")
305
306
  const controlPath = ownerControlPath(ownerState)
@@ -408,10 +409,7 @@ async function execute(request, socket) {
408
409
  return managedProcess.status()
409
410
  }
410
411
 
411
- if (!request.key) throw new Error(`Guardian ${request.command} requires a process key`)
412
- const record = processes.get(request.key)
413
-
414
- if (!record) throw new Error(`Guardian process ${request.key} is not registered`)
412
+ const record = requireProcess(request)
415
413
 
416
414
  if (request.command !== "status") requireOwner(socket, request.command)
417
415
 
@@ -530,6 +528,18 @@ function requireReplacement(socket, request) {
530
528
  if (replacementClient !== socket || request.replacementId !== replacementId) throw new Error("Owner replacement transaction is not the prepared candidate")
531
529
  }
532
530
 
531
+ /**
532
+ * @param {GuardianRequest} request - Keyed guardian request.
533
+ * @returns {{desired: boolean, process: ManagedProcess, provenance: string}} Exact registered process.
534
+ */
535
+ function requireProcess(request) {
536
+ if (!request.key) throw new Error(`Guardian ${request.command} requires a process key`)
537
+ const record = processes.get(request.key)
538
+
539
+ if (!record) throw new Error(`Guardian process ${request.key} is not registered`)
540
+ return record
541
+ }
542
+
533
543
  /**
534
544
  * @param {import("./json.js").JsonValue} state - Transfer state.
535
545
  * @returns {import("./json.js").JsonValue} Embedded authority fence.
@@ -210,32 +210,68 @@ test("replacement staging rejects owner state published after prepare", async ()
210
210
  }
211
211
  })
212
212
 
213
+ test("retired owner replacement commit carries its exact recovered process key", async () => {
214
+ const client = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
215
+ const replacementId = "prepared-replacement"
216
+ const processKey = "release:v1:worker"
217
+
218
+ client.request = async (request) => {
219
+ if (!request.key) throw new Error(`Guardian ${request.command} requires a process key`)
220
+ assert.deepEqual(request, {command: "commit-retired-owner-replacement", key: processKey, replacementId})
221
+ return {committed: true}
222
+ }
223
+
224
+ await client.commitRetiredOwnerReplacement(replacementId, processKey)
225
+ })
226
+
213
227
  test("retired owner replacement requires unchanged authority and the exact control path absent", async () => {
214
228
  const fixture = await createGuardian()
215
229
  const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
230
+ const contender = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
216
231
  const controlPath = path.join(fixture.root, "rollbridge.sock")
232
+ const processKey = "release:v1:worker"
217
233
  const authority = {configDigest: "incumbent", runtime: null}
218
234
  const nextAuthority = {configDigest: "candidate", runtime: null}
219
235
  const snapshot = {activeReleaseId: "v1", control: {path: controlPath}}
220
236
 
221
237
  try {
238
+ await fixture.client.process(processKey, definition("worker")).recover()
222
239
  await fixture.client.publishOwnerState({authority, snapshot})
223
240
  await candidate.connect()
224
241
  const changed = await candidate.prepareOwnerReplacement(authority, nextAuthority)
225
242
 
226
243
  await candidate.stageOwnerReplacement(changed.replacementId, {authority: nextAuthority, snapshot})
227
- await assert.rejects(() => candidate.commitRetiredOwnerReplacement(changed.replacementId), /unchanged owner authority/)
244
+ await assert.rejects(() => candidate.commitRetiredOwnerReplacement(changed.replacementId, processKey), /unchanged owner authority/)
228
245
  await candidate.abortOwnerReplacement(changed.replacementId)
229
246
 
230
247
  const occupied = await candidate.prepareOwnerReplacement(authority, authority)
231
248
 
232
249
  await candidate.stageOwnerReplacement(occupied.replacementId, {authority, snapshot})
233
250
  await fs.writeFile(controlPath, "occupied\n")
234
- await assert.rejects(() => candidate.commitRetiredOwnerReplacement(occupied.replacementId), /control socket .* still exists/)
251
+ await assert.rejects(() => candidate.commitRetiredOwnerReplacement(occupied.replacementId, processKey), /control socket .* still exists/)
235
252
  await candidate.abortOwnerReplacement(occupied.replacementId)
236
- await fixture.client.shutdown()
253
+
254
+ await fs.rm(controlPath)
255
+ const ready = await candidate.prepareOwnerReplacement(authority, authority)
256
+
257
+ await candidate.stageOwnerReplacement(ready.replacementId, {authority, snapshot})
258
+ await contender.connect()
259
+ await assert.rejects(
260
+ () => contender.request({command: "commit-retired-owner-replacement", key: processKey, replacementId: ready.replacementId}),
261
+ /not the prepared candidate/
262
+ )
263
+ await assert.rejects(
264
+ () => candidate.commitRetiredOwnerReplacement(ready.replacementId, "release:v1:wrong"),
265
+ /process .* is not registered/
266
+ )
267
+ const committed = candidate.waitForEvent("replacement-committed")
268
+
269
+ await candidate.commitRetiredOwnerReplacement(ready.replacementId, processKey)
270
+ await committed
271
+ await candidate.shutdown()
237
272
  await fixture.client.guardianExit()
238
273
  } finally {
274
+ contender.disconnect()
239
275
  candidate.disconnect()
240
276
  await cleanupGuardian(fixture)
241
277
  }