rollbridge 0.1.33 → 0.1.35

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.
@@ -1,5 +1,9 @@
1
1
  ### Fixed
2
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.
3
+ - Select retired-owner commit proof from the guardian-published committed owner
4
+ snapshot rather than candidate-local process order, and independently require
5
+ that proof to remain registered while retaining the replacement transaction,
6
+ authority, and control-path fences.
7
+ - Fail closed when an older retained guardian cannot commit that replacement
8
+ atomically after the incumbent control socket disappears, preserving the
9
+ incumbent owner, retained connections, and guardian-managed release processes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/daemon.js CHANGED
@@ -353,6 +353,7 @@ export default class RollbridgeDaemon {
353
353
  let retiredIncumbentControl = false
354
354
  let retainIncumbentControl = false
355
355
  let incumbentControl = legacyBridge?.incumbentControl
356
+ let committed = /** @type {Promise<Error | undefined> | undefined} */ (undefined)
356
357
 
357
358
  try {
358
359
  if (this.config.control.path !== transfer.snapshot.control.path) {
@@ -401,7 +402,10 @@ export default class RollbridgeDaemon {
401
402
  finalControlPublished = true
402
403
  this.boundControlPath = this.config.control.path
403
404
  }
404
- const committed = this.guardian.waitForEvent("replacement-committed")
405
+ committed = this.guardian.waitForEvent("replacement-committed").then(
406
+ () => undefined,
407
+ (error) => error instanceof Error ? error : new Error(String(error))
408
+ )
405
409
  const staged = await this.guardian.stageOwnerReplacement(prepared.replacementId, {
406
410
  authority: this.ownerAuthority(),
407
411
  config: this.config,
@@ -412,10 +416,20 @@ export default class RollbridgeDaemon {
412
416
 
413
417
  if (!staged.committed) {
414
418
  if (retiredIncumbentControl) {
415
- const processKey = this.guardian.processes.keys().next().value
419
+ const recoveredProcesses = this.guardian.processes
420
+ const processKey = ownerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
421
+ .find((key) => recoveredProcesses.has(key))
416
422
 
417
- if (!processKey) throw new Error("Retired owner replacement requires an exact recovered guardian process registration")
418
- await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId, processKey)
423
+ if (!processKey) throw new Error("Retired owner replacement requires an exact recovered process from committed owner state")
424
+ try {
425
+ await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId, processKey)
426
+ } catch (error) {
427
+ if (!(error instanceof Error) || error.message !== "Guardian commit-retired-owner-replacement requires the committed owner") throw error
428
+ throw new Error(
429
+ "Cannot safely complete atomic owner replacement through the older retained guardian while the incumbent control socket is absent; incumbent owner and connections were preserved",
430
+ {cause: error}
431
+ )
432
+ }
419
433
  } else {
420
434
  try {
421
435
  if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
@@ -428,7 +442,9 @@ export default class RollbridgeDaemon {
428
442
  }
429
443
  }
430
444
  }
431
- await committed
445
+ const commitmentError = await committed
446
+
447
+ if (commitmentError) throw commitmentError
432
448
  committedAuthority = true
433
449
  if (retiredIncumbentControl) {
434
450
  await fs.rename(stagingControlPath, this.config.control.path)
@@ -475,6 +491,13 @@ export default class RollbridgeDaemon {
475
491
  } else {
476
492
  this.guardian.disconnect()
477
493
  }
494
+ if (committed) {
495
+ const commitmentError = await committed
496
+
497
+ if (commitmentError && commitmentError !== error && commitmentError.message !== "Process guardian connection closed") {
498
+ abortError = commitmentError
499
+ }
500
+ }
478
501
  incumbentControl?.close()
479
502
  if (legacyBridge?.boundaryCrossed) {
480
503
  this.logger("legacy disruptive owner replacement failed after incumbent exit", {
@@ -523,7 +546,7 @@ export default class RollbridgeDaemon {
523
546
  */
524
547
  async prepareLegacyOwnerReplacement({error, persisted, persistedAuthority}) {
525
548
  const diagnostic = error instanceof Error ? error.message : String(error)
526
- const legacyProcessKey = legacyGuardianKeys(persisted)[0]
549
+ const legacyProcessKey = ownerSnapshotProcessKeys(persisted, persisted.singletonReleaseIds)[0]
527
550
 
528
551
  if (!isLegacyGuardianPrepareDiagnostic(diagnostic)) throw error
529
552
  if (!legacyProcessKey) throw new Error("Legacy disruptive owner replacement requires an exact guardian-owned process registration in durable state", {cause: error})
@@ -1975,18 +1998,21 @@ export function isLegacyGuardianPrepareDiagnostic(diagnostic) {
1975
1998
  }
1976
1999
 
1977
2000
  /**
1978
- * @param {OwnerRecoverySnapshot} snapshot - Durable pre-split snapshot.
2001
+ * @param {OwnerRecoverySnapshot} snapshot - Durable committed owner snapshot.
2002
+ * @param {Record<string, string>} [singletonReleaseIds] - Exact singleton owner releases.
1979
2003
  * @returns {string[]} Exact guardian registration keys present in the snapshot.
1980
2004
  */
1981
- function legacyGuardianKeys(snapshot) {
2005
+ function ownerSnapshotProcessKeys(snapshot, singletonReleaseIds = {}) {
1982
2006
  const keys = []
1983
2007
 
1984
2008
  for (const release of snapshot.releases) {
1985
2009
  for (const processStatus of release.processes) keys.push(`release:${release.releaseId}:${processStatus.id}`)
1986
2010
  }
1987
2011
  for (const service of snapshot.services) keys.push(`service:${service.id}`)
1988
- if (snapshot.activeReleaseId) {
1989
- for (const singleton of snapshot.singletons) keys.push(`singleton:${snapshot.activeReleaseId}:${singleton.id}`)
2012
+ for (const singleton of snapshot.singletons) {
2013
+ const releaseId = singletonReleaseIds[singleton.id] || snapshot.activeReleaseId
2014
+
2015
+ if (releaseId) keys.push(`singleton:${releaseId}:${singleton.id}`)
1990
2016
  }
1991
2017
  return keys
1992
2018
  }
@@ -68,7 +68,7 @@ let ownerState = bootstrap.ownerState
68
68
  let ownerRevision = ownerState === undefined ? 0 : 1
69
69
  /** @type {number | undefined} */
70
70
  let replacementRevision
71
- const legacyKeys = legacyGuardian ? legacyOwnerKeys(ownerState) : new Set()
71
+ const legacyKeys = legacyGuardian ? committedOwnerProcessKeys(ownerState) : new Set()
72
72
  /** @type {net.Socket | undefined} */
73
73
  let ownerMutationClient
74
74
  /** @type {string | undefined} */
@@ -301,6 +301,9 @@ async function execute(request, socket) {
301
301
  if (request.command === "commit-retired-owner-replacement") {
302
302
  requireReplacement(socket, request)
303
303
  requireProcess(request)
304
+ if (!committedOwnerProcessKeys(ownerState).has(/** @type {string} */ (request.key))) {
305
+ throw new Error(`Guardian process ${request.key} does not belong to the committed owner`)
306
+ }
304
307
  if (!replacementOwnerState) throw new Error("Retired owner replacement transaction is not staged")
305
308
  if (!isDeepStrictEqual(ownerAuthority(ownerState), replacementAuthority)) throw new Error("Retired owner replacement requires unchanged owner authority")
306
309
  const controlPath = ownerControlPath(ownerState)
@@ -622,11 +625,11 @@ function errorMessage(error) {
622
625
  }
623
626
 
624
627
  /**
625
- * Derives only exact process keys present in a committed pre-split durable snapshot.
626
- * @param {import("./json.js").JsonValue | undefined} state - Seeded owner state.
627
- * @returns {Set<string>} Exact legacy registrations eligible for recovery.
628
+ * Derives only exact process keys serialized by committed private owner state.
629
+ * @param {import("./json.js").JsonValue | undefined} state - Committed owner state.
630
+ * @returns {Set<string>} Exact committed registrations eligible as owner proof.
628
631
  */
629
- function legacyOwnerKeys(state) {
632
+ function committedOwnerProcessKeys(state) {
630
633
  const keys = new Set()
631
634
 
632
635
  if (!state || typeof state !== "object" || Array.isArray(state) || !("snapshot" in state)) return keys
@@ -646,9 +649,18 @@ function legacyOwnerKeys(state) {
646
649
  if (service && typeof service === "object" && !Array.isArray(service) && typeof service.id === "string") keys.add(`service:${service.id}`)
647
650
  }
648
651
  }
649
- if (typeof snapshot.activeReleaseId === "string" && Array.isArray(snapshot.singletons)) {
652
+ const singletonReleaseIds = "singletonReleaseIds" in state && state.singletonReleaseIds && typeof state.singletonReleaseIds === "object" && !Array.isArray(state.singletonReleaseIds)
653
+ ? state.singletonReleaseIds
654
+ : {}
655
+
656
+ if (Array.isArray(snapshot.singletons)) {
650
657
  for (const singleton of snapshot.singletons) {
651
- if (singleton && typeof singleton === "object" && !Array.isArray(singleton) && typeof singleton.id === "string") keys.add(`singleton:${snapshot.activeReleaseId}:${singleton.id}`)
658
+ if (!singleton || typeof singleton !== "object" || Array.isArray(singleton) || typeof singleton.id !== "string") continue
659
+ const releaseId = singleton.id in singletonReleaseIds && typeof singletonReleaseIds[singleton.id] === "string"
660
+ ? singletonReleaseIds[singleton.id]
661
+ : snapshot.activeReleaseId
662
+
663
+ if (typeof releaseId === "string") keys.add(`singleton:${releaseId}:${singleton.id}`)
652
664
  }
653
665
  }
654
666
  return keys
@@ -224,6 +224,42 @@ test("retired owner replacement commit carries its exact recovered process key",
224
224
  await client.commitRetiredOwnerReplacement(replacementId, processKey)
225
225
  })
226
226
 
227
+ test("retired owner replacement rejects a registered process absent from committed owner state", async () => {
228
+ const fixture = await createGuardian()
229
+ const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
230
+ const committedProcessKey = "release:v1:worker"
231
+ const candidateProcessKey = "release:candidate:worker"
232
+ const authority = {configDigest: "owner", runtime: null}
233
+ const snapshot = {
234
+ activeReleaseId: "v1",
235
+ control: {path: path.join(fixture.root, "rollbridge.sock")},
236
+ releases: [{processes: [{id: "worker"}], releaseId: "v1"}],
237
+ services: [],
238
+ singletons: []
239
+ }
240
+ const candidateSnapshot = {
241
+ ...snapshot,
242
+ releases: [...snapshot.releases, {processes: [{id: "worker"}], releaseId: "candidate"}]
243
+ }
244
+
245
+ try {
246
+ await fixture.client.process(committedProcessKey, definition("worker")).recover()
247
+ await fixture.client.process(candidateProcessKey, definition("candidate-worker")).recover()
248
+ await fixture.client.publishOwnerState({authority, snapshot})
249
+ await candidate.connect()
250
+ const prepared = await candidate.prepareOwnerReplacement(authority, authority)
251
+
252
+ await candidate.stageOwnerReplacement(prepared.replacementId, {authority, snapshot: candidateSnapshot})
253
+ await assert.rejects(
254
+ () => candidate.commitRetiredOwnerReplacement(prepared.replacementId, candidateProcessKey),
255
+ /process .* does not belong to the committed owner/
256
+ )
257
+ } finally {
258
+ candidate.disconnect()
259
+ await cleanupGuardian(fixture)
260
+ }
261
+ })
262
+
227
263
  test("retired owner replacement requires unchanged authority and the exact control path absent", async () => {
228
264
  const fixture = await createGuardian()
229
265
  const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
@@ -232,7 +268,13 @@ test("retired owner replacement requires unchanged authority and the exact contr
232
268
  const processKey = "release:v1:worker"
233
269
  const authority = {configDigest: "incumbent", runtime: null}
234
270
  const nextAuthority = {configDigest: "candidate", runtime: null}
235
- const snapshot = {activeReleaseId: "v1", control: {path: controlPath}}
271
+ const snapshot = {
272
+ activeReleaseId: "v1",
273
+ control: {path: controlPath},
274
+ releases: [{processes: [{id: "worker"}], releaseId: "v1"}],
275
+ services: [],
276
+ singletons: []
277
+ }
236
278
 
237
279
  try {
238
280
  await fixture.client.process(processKey, definition("worker")).recover()
@@ -260,6 +302,10 @@ test("retired owner replacement requires unchanged authority and the exact contr
260
302
  () => contender.request({command: "commit-retired-owner-replacement", key: processKey, replacementId: ready.replacementId}),
261
303
  /not the prepared candidate/
262
304
  )
305
+ await assert.rejects(
306
+ () => candidate.commitRetiredOwnerReplacement("stale-replacement", processKey),
307
+ /not the prepared candidate/
308
+ )
263
309
  await assert.rejects(
264
310
  () => candidate.commitRetiredOwnerReplacement(ready.replacementId, "release:v1:wrong"),
265
311
  /process .* is not registered/
@@ -249,13 +249,161 @@ test("ensure-daemon atomically replaces incompatible config, socket, and package
249
249
  }
250
250
  })
251
251
 
252
- test("same-authority replacement commits after a retired incumbent already removed its control socket", async () => {
252
+ test("cross-version replacement fails closed without dropping a retained WebSocket after the public socket is removed", async () => {
253
253
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-retired-control-"))
254
254
  const socketPath = path.join(root, "rollbridge.sock")
255
255
  const statePath = path.join(root, "state.json")
256
+ const compatibilitySocketPath = path.join(root, "retained-guardian.sock")
257
+ const configPath = path.join(root, "rollbridge.cjs")
258
+ const releasePath = path.join(root, "v1")
259
+ const compatibilitySockets = new Set()
260
+ let compatibilityGuardian
261
+ let committedProcessKey
262
+ let retainedGuardianSocketPath = /** @type {string | undefined} */ (undefined)
263
+ let owner = /** @type {import("node:child_process").ChildProcess | undefined} */ (undefined)
264
+ let replacement = /** @type {RollbridgeDaemon | undefined} */ (undefined)
265
+ let retainedConnection
266
+ let retainedConnectionClosed = false
267
+ let transactionAudit
268
+
269
+ try {
270
+ await fs.mkdir(releasePath)
271
+ await makeFifo(path.join(releasePath, "worker.fifo"))
272
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
273
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
274
+ await waitForLog(owner, "control socket listening")
275
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
276
+ const retired = await sendControlCommand({command: {command: "status"}, path: socketPath})
277
+ const retiredReleases = /** @type {{processes: {id: string, pid?: number, state: string}[]}[]} */ (retired.releases)
278
+ const processState = retiredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
279
+ const proxyPort = /** @type {{port?: number}} */ (retired.proxy).port
280
+
281
+ assert.deepEqual(processState?.map(({state}) => state), ["running", "running"])
282
+ if (typeof proxyPort !== "number") throw new Error("Retained owner proxy is missing its port")
283
+ retainedConnection = await openWebSocket(proxyPort)
284
+ retainedConnection.once("close", () => { retainedConnectionClosed = true })
285
+ await fs.rm(socketPath)
286
+ await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
287
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"))
288
+ const guardianSocketPath = state.recovery.guardian.socketPath
289
+ const expectedProcessKey = "release:v1:worker"
290
+
291
+ if (typeof guardianSocketPath !== "string") throw new Error("Retained guardian state is missing its socket path")
292
+ retainedGuardianSocketPath = guardianSocketPath
293
+
294
+ compatibilityGuardian = net.createServer((candidateSocket) => {
295
+ const guardianSocket = net.createConnection(guardianSocketPath)
296
+ let buffer = ""
297
+
298
+ compatibilitySockets.add(candidateSocket)
299
+ compatibilitySockets.add(guardianSocket)
300
+ candidateSocket.setEncoding("utf8")
301
+ candidateSocket.once("close", () => {
302
+ compatibilitySockets.delete(candidateSocket)
303
+ guardianSocket.destroy()
304
+ })
305
+ guardianSocket.once("close", () => {
306
+ compatibilitySockets.delete(guardianSocket)
307
+ candidateSocket.destroy()
308
+ })
309
+ guardianSocket.on("data", (chunk) => candidateSocket.write(chunk))
310
+ candidateSocket.on("data", (chunk) => {
311
+ buffer += chunk
312
+ let newline = buffer.indexOf("\n")
313
+
314
+ while (newline >= 0) {
315
+ const line = buffer.slice(0, newline)
316
+ const request = JSON.parse(line)
317
+
318
+ buffer = buffer.slice(newline + 1)
319
+ if (request.command === "commit-retired-owner-replacement") {
320
+ if (request.key !== expectedProcessKey) {
321
+ candidateSocket.write(`${JSON.stringify({error: `Guardian ${request.command} requires a process key`, id: request.id})}\n`)
322
+ } else {
323
+ committedProcessKey = request.key
324
+ candidateSocket.write(`${JSON.stringify({error: `Guardian ${request.command} requires the committed owner`, id: request.id})}\n`)
325
+ }
326
+ newline = buffer.indexOf("\n")
327
+ continue
328
+ }
329
+ guardianSocket.write(`${line}\n`)
330
+ newline = buffer.indexOf("\n")
331
+ }
332
+ })
333
+ })
334
+ await listenUnix(compatibilityGuardian, compatibilitySocketPath)
335
+ state.recovery.guardian.socketPath = compatibilitySocketPath
336
+ await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
337
+
338
+ const incumbentPid = owner.pid
339
+
340
+ assert.ok(incumbentPid)
341
+ const candidate = new RollbridgeDaemon({
342
+ config: normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath})),
343
+ configPath,
344
+ legacyIncumbentPid: incumbentPid,
345
+ logger: () => {}
346
+ })
347
+ replacement = candidate
348
+ await assert.rejects(
349
+ () => candidate.replaceIncompatibleOwner(),
350
+ /cannot safely complete atomic owner replacement through the older retained guardian while the incumbent control socket is absent; incumbent owner and connections were preserved/i
351
+ )
352
+ assert.equal(committedProcessKey, expectedProcessKey)
353
+ assert.equal(owner.exitCode, null)
354
+ assert.equal(owner.signalCode, null)
355
+ assert.doesNotThrow(() => process.kill(incumbentPid, 0))
356
+ assert.equal(retainedConnectionClosed, false, "failed compatibility handoff must leave retained connections serving")
357
+ assert.equal(retainedConnection.destroyed, false, "failed compatibility handoff must preserve the incumbent listener")
358
+ for (const {pid} of processState || []) {
359
+ if (typeof pid !== "number") throw new Error("Retained process is missing its PID")
360
+ assert.doesNotThrow(() => process.kill(pid, 0))
361
+ }
362
+ transactionAudit = new GuardianClient(state.recovery.guardian)
363
+ await transactionAudit.connect()
364
+ const transactionStatus = /** @type {{committedReplacementId: string | null, ownerClaimed: boolean, retirementPending?: boolean}} */ (await transactionAudit.replacementStatus())
365
+
366
+ assert.equal(transactionStatus.committedReplacementId, null)
367
+ assert.equal(transactionStatus.ownerClaimed, true)
368
+ assert.equal(transactionStatus.retirementPending, false)
369
+ } finally {
370
+ if (replacement?.controlCommandsReady) {
371
+ await Promise.all([
372
+ fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}),
373
+ replacement.shutdown().catch(() => {})
374
+ ])
375
+ }
376
+ transactionAudit?.disconnect()
377
+ retainedConnection?.destroy()
378
+ if (owner && owner.exitCode === null && owner.signalCode === null) {
379
+ owner.kill("SIGKILL")
380
+ await once(owner, "exit")
381
+ }
382
+ replacement?.guardian?.disconnect()
383
+ for (const socket of compatibilitySockets) socket.destroy()
384
+ if (compatibilityGuardian?.listening) await closeServer(compatibilityGuardian)
385
+ if (retainedGuardianSocketPath) {
386
+ const cleanupState = JSON.parse(await fs.readFile(statePath, "utf8"))
387
+
388
+ cleanupState.recovery.guardian.socketPath = retainedGuardianSocketPath
389
+ await fs.writeFile(statePath, `${JSON.stringify(cleanupState)}\n`)
390
+ }
391
+ await stopGuardian(statePath)
392
+ await fs.rm(root, {force: true, recursive: true})
393
+ }
394
+ })
395
+
396
+ test("same-authority replacement commits with committed-owner proof after the incumbent control socket is removed", async () => {
397
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-committed-proof-"))
398
+ const socketPath = path.join(root, "rollbridge.sock")
399
+ const statePath = path.join(root, "state.json")
256
400
  const releasePath = path.join(root, "v1")
257
401
  const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath}))
258
402
  const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
403
+ const candidateProcessKey = "release:candidate:worker"
404
+ const committedOwnerProcessKey = "release:v1:worker"
405
+ let committedProcessKey
406
+ /** @type {RollbridgeDaemon | undefined} */
259
407
  let replacement
260
408
 
261
409
  try {
@@ -263,6 +411,16 @@ test("same-authority replacement commits after a retired incumbent already remov
263
411
  await makeFifo(path.join(releasePath, "worker.fifo"))
264
412
  await owner.start()
265
413
  await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
414
+ const ownerProcess = owner.guardian?.processes.values().next().value
415
+
416
+ assert.ok(owner.guardian)
417
+ assert.ok(ownerProcess)
418
+ await owner.guardian.request({
419
+ command: "register",
420
+ definition: ownerProcess.definition,
421
+ key: candidateProcessKey,
422
+ provenance: ownerProcess.provenance
423
+ })
266
424
  await Promise.all([...owner.releases.values()].map((release) => release.quiesce()))
267
425
  await owner.closeServer(owner.controlServer)
268
426
  await owner.removeControlSocket()
@@ -274,12 +432,29 @@ test("same-authority replacement commits after a retired incumbent already remov
274
432
  assert.equal((await owner.guardian?.replacementStatus())?.ownerClaimed, true)
275
433
  await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
276
434
 
277
- replacement = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
435
+ replacement = new RollbridgeDaemon({
436
+ config: daemonConfig,
437
+ logger: (message) => {
438
+ if (message !== "owner replacement candidate prepared" || !replacement?.guardian) return
439
+ const guardian = replacement.guardian
440
+ const recoveredProcess = guardian.processes.values().next().value
441
+
442
+ if (!recoveredProcess) throw new Error("Replacement did not reconstruct a guardian process")
443
+ guardian.processes = new Map([[candidateProcessKey, recoveredProcess], ...guardian.processes])
444
+ const commitRetiredOwnerReplacement = guardian.commitRetiredOwnerReplacement.bind(guardian)
445
+
446
+ guardian.commitRetiredOwnerReplacement = async (replacementId, processKey) => {
447
+ committedProcessKey = processKey
448
+ await commitRetiredOwnerReplacement(replacementId, processKey)
449
+ }
450
+ }
451
+ })
278
452
  await replacement.replaceIncompatibleOwner()
279
453
  const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
280
454
  const recoveredReleases = /** @type {{processes: {id: string, pid?: number, state: string}[]}[]} */ (recovered.releases)
281
455
 
282
456
  assert.equal(recovered.activeReleaseId, "v1")
457
+ assert.equal(committedProcessKey, committedOwnerProcessKey)
283
458
  assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
284
459
  assert.deepEqual(recoveredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state})), processState)
285
460
  } finally {