rollbridge 0.1.29 → 0.1.31

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.29",
3
+ "version": "0.1.31",
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
@@ -300,6 +300,7 @@ export default class RollbridgeDaemon {
300
300
  let stagingControlPath
301
301
  let finalControlPublished = false
302
302
  let listenersYielded = false
303
+ let retiredIncumbentControl = false
303
304
  let retainIncumbentControl = false
304
305
  let incumbentControl = legacyBridge?.incumbentControl
305
306
 
@@ -325,22 +326,31 @@ export default class RollbridgeDaemon {
325
326
  if (legacyBridge) {
326
327
  await this.crossLegacyDisruptiveBoundary(legacyBridge)
327
328
  } else if (preparedStatus.ownerClaimed) {
328
- incumbentControl = await openControlSession(transfer.snapshot.control.path)
329
- const listenerSession = incumbentControl
330
-
331
- incumbentControl.onEvent((event) => this.handleIncumbentListenerEvent(event, listenerSession))
332
- await incumbentControl.request({
333
- command: "yield-owner-listeners",
334
- control: transfer.snapshot.control.path === this.config.control.path,
335
- proxy: true,
336
- replacementId: prepared.replacementId
337
- })
338
- listenersYielded = true
329
+ try {
330
+ incumbentControl = await openControlSession(transfer.snapshot.control.path)
331
+ } catch (error) {
332
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
333
+ retiredIncumbentControl = true
334
+ }
335
+ if (incumbentControl) {
336
+ const listenerSession = incumbentControl
337
+
338
+ incumbentControl.onEvent((event) => this.handleIncumbentListenerEvent(event, listenerSession))
339
+ await incumbentControl.request({
340
+ command: "yield-owner-listeners",
341
+ control: transfer.snapshot.control.path === this.config.control.path,
342
+ proxy: true,
343
+ replacementId: prepared.replacementId
344
+ })
345
+ listenersYielded = true
346
+ }
339
347
  }
340
348
  if (sharedFixedProxy) await this.startProxy()
341
- await fs.rename(stagingControlPath, this.config.control.path)
342
- finalControlPublished = true
343
- this.boundControlPath = this.config.control.path
349
+ if (!retiredIncumbentControl) {
350
+ await fs.rename(stagingControlPath, this.config.control.path)
351
+ finalControlPublished = true
352
+ this.boundControlPath = this.config.control.path
353
+ }
344
354
  const committed = this.guardian.waitForEvent("replacement-committed")
345
355
  const staged = await this.guardian.stageOwnerReplacement(prepared.replacementId, {
346
356
  authority: this.ownerAuthority(),
@@ -350,18 +360,27 @@ export default class RollbridgeDaemon {
350
360
  })
351
361
 
352
362
  if (!staged.committed) {
353
- try {
354
- if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
355
- await incumbentControl.request({command: "commit-owner-replacement", replacementId: prepared.replacementId})
356
- } catch (error) {
357
- const status = await this.guardian.replacementStatus()
358
-
359
- if (status.committedReplacementId !== prepared.replacementId || !status.ownerClaimed) throw error
360
- this.logger("owner replacement commit response lost; guardian commit confirmed", {replacementId: prepared.replacementId})
363
+ if (retiredIncumbentControl) {
364
+ await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId)
365
+ } else {
366
+ try {
367
+ if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
368
+ await incumbentControl.request({command: "commit-owner-replacement", replacementId: prepared.replacementId})
369
+ } catch (error) {
370
+ const status = await this.guardian.replacementStatus()
371
+
372
+ if (status.committedReplacementId !== prepared.replacementId || !status.ownerClaimed) throw error
373
+ this.logger("owner replacement commit response lost; guardian commit confirmed", {replacementId: prepared.replacementId})
374
+ }
361
375
  }
362
376
  }
363
377
  await committed
364
378
  committedAuthority = true
379
+ if (retiredIncumbentControl) {
380
+ await fs.rename(stagingControlPath, this.config.control.path)
381
+ finalControlPublished = true
382
+ this.boundControlPath = this.config.control.path
383
+ }
365
384
  if (!legacyBridge && incumbentControl && [...this.releases.values()].some((release) => release.hasTransferredConnections())) {
366
385
  this.incumbentListenerControl = incumbentControl
367
386
  retainIncumbentControl = true
@@ -1437,11 +1456,11 @@ export default class RollbridgeDaemon {
1437
1456
  /**
1438
1457
  * Persists a state snapshot (status plus recent events) to statePath, atomically and
1439
1458
  * fire-and-forget unless the caller awaits the returned write. A failed write is logged.
1440
- * @param {{throwOnError?: boolean}} [options] - Whether a write failure rejects the returned promise.
1459
+ * @param {{allowStopping?: boolean, throwOnError?: boolean}} [options] - Write behavior.
1441
1460
  * @returns {Promise<void> | undefined} The queued write, or undefined when persistence is disabled.
1442
1461
  */
1443
- persistState({throwOnError = false} = {}) {
1444
- if (!this.statePath || !this.persistenceEnabled || this.stopping) return
1462
+ persistState({allowStopping = false, throwOnError = false} = {}) {
1463
+ if (!this.statePath || !this.persistenceEnabled || (this.stopping && !allowStopping)) return
1445
1464
 
1446
1465
  const statePath = this.statePath
1447
1466
  const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
@@ -1533,25 +1552,35 @@ export default class RollbridgeDaemon {
1533
1552
  clearInterval(this.persistTimer)
1534
1553
  this.persistTimer = undefined
1535
1554
  }
1536
- this.persistenceEnabled = false
1537
1555
  if (this.pendingWrite) await this.pendingWrite
1538
1556
  this.stateCleanupEnabled = false
1539
1557
  this.controlClosePromise = this.closeServer(this.controlServer)
1540
1558
  for (const socket of this.controlSockets) if (socket !== completionSocket) socket.destroy()
1559
+ if (this.activeRelease) {
1560
+ await this.activeRelease.beginRetirement(this.activeRelease.config)
1561
+ this.activeRelease = undefined
1562
+ }
1541
1563
  await Promise.all([
1542
1564
  ...[...this.services.values()].map((processInstance) => processInstance.quiesce()),
1543
1565
  ...[...this.singletons.values()].map((processInstance) => processInstance.quiesce()),
1544
1566
  ...[...this.startingReleases].map((release) => release.quiesce()),
1545
1567
  ...[...this.releases.values()].map((release) => release.quiesce())
1546
1568
  ])
1569
+ await this.persistState({allowStopping: true, throwOnError: true})
1570
+ this.persistenceEnabled = false
1547
1571
  await this.removeControlSocket()
1548
1572
  void this.closeServer(this.proxyServer)
1549
- void Promise.allSettled([
1550
- ...[...this.services.values()].map((processInstance) => processInstance.stop()),
1551
- ...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
1552
- ...[...this.startingReleases].map((release) => release.stop()),
1553
- ...[...this.releases.values()].map((release) => release.stop())
1554
- ])
1573
+ if (this.guardian) {
1574
+ await this.guardian.retireOwner()
1575
+ this.guardian.disconnect()
1576
+ } else {
1577
+ void Promise.allSettled([
1578
+ ...[...this.services.values()].map((processInstance) => processInstance.stop()),
1579
+ ...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
1580
+ ...[...this.startingReleases].map((release) => release.stop()),
1581
+ ...[...this.releases.values()].map((release) => release.stop())
1582
+ ])
1583
+ }
1555
1584
  this.logger("external owner retired", {attestation, status: "draining"})
1556
1585
  }
1557
1586
 
@@ -156,6 +156,11 @@ export default class GuardianClient {
156
156
  await this.request({authority, command: "claim-owner", graceMs})
157
157
  }
158
158
 
159
+ /** Starts graceful process retirement and relinquishes committed owner authority. */
160
+ async retireOwner() {
161
+ await this.request({command: "retire-owner"})
162
+ }
163
+
159
164
  /** @param {import("./json.js").JsonValue} ownerState - Private transferable owner state. */
160
165
  async publishOwnerState(ownerState) {
161
166
  await this.request({command: "publish-owner-state", ownerState})
@@ -189,6 +194,11 @@ export default class GuardianClient {
189
194
  await this.request({command: "commit-owner-replacement", replacementId})
190
195
  }
191
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})
200
+ }
201
+
192
202
  /** @param {string} replacementId - Committed transaction awaiting incumbent retirement. */
193
203
  async finalizeOwnerReplacement(replacementId) {
194
204
  await this.request({command: "finalize-owner-replacement", replacementId})
@@ -200,6 +200,19 @@ async function execute(request, socket) {
200
200
  })
201
201
  }
202
202
 
203
+ if (request.command === "retire-owner") {
204
+ requireOwner(socket, request.command)
205
+ if (replacementClient) throw new Error("Committed owner cannot retire while an owner replacement is prepared")
206
+ for (const entry of processes.values()) entry.desired = false
207
+ void Promise.allSettled([...processes.values()].map((entry) => entry.process.stop()))
208
+ ownerClient = undefined
209
+ ownerMutationClient = undefined
210
+ ownerMutationId = undefined
211
+ ownerRevision += 1
212
+ grantNextOwner()
213
+ return {retired: true}
214
+ }
215
+
203
216
  if (request.command === "abandon-legacy-upgrade") {
204
217
  if (!legacyGuardian) throw new Error("Guardian is not a legacy upgrade coordinator")
205
218
  if (ownerClient || committedReplacementId) throw new Error("Committed guardian authority cannot abandon its legacy backend")
@@ -284,6 +297,23 @@ async function execute(request, socket) {
284
297
  return {aborted: true}
285
298
  }
286
299
 
300
+ if (request.command === "commit-retired-owner-replacement") {
301
+ requireReplacement(socket, request)
302
+ if (!replacementOwnerState) throw new Error("Retired owner replacement transaction is not staged")
303
+ if (!isDeepStrictEqual(ownerAuthority(ownerState), replacementAuthority)) throw new Error("Retired owner replacement requires unchanged owner authority")
304
+ const controlPath = ownerControlPath(ownerState)
305
+
306
+ try {
307
+ await fs.lstat(controlPath)
308
+ throw new Error(`Retired owner control socket ${controlPath} still exists`)
309
+ } catch (error) {
310
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
311
+ }
312
+ commitReplacement()
313
+ finalizeReplacementRetirement()
314
+ return {committed: true}
315
+ }
316
+
287
317
  if (request.command === "commit-owner-replacement") {
288
318
  requireOwner(socket, request.command)
289
319
  if (!replacementClient || request.replacementId !== replacementId || !replacementOwnerState) throw new Error("Owner replacement transaction is not the prepared ready candidate")
@@ -497,6 +527,21 @@ function ownerAuthority(state) {
497
527
  return state.authority
498
528
  }
499
529
 
530
+ /**
531
+ * @param {import("./json.js").JsonValue | undefined} state - Committed transferable state.
532
+ * @returns {string} Exact incumbent public control path.
533
+ */
534
+ function ownerControlPath(state) {
535
+ if (!state || typeof state !== "object" || Array.isArray(state) || !("snapshot" in state)) throw new Error("Guardian owner state is missing its committed snapshot")
536
+ const snapshot = state.snapshot
537
+
538
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot) || !("control" in snapshot)) throw new Error("Guardian owner snapshot is missing its control identity")
539
+ const control = snapshot.control
540
+
541
+ if (!control || typeof control !== "object" || Array.isArray(control) || !("path" in control) || typeof control.path !== "string") throw new Error("Guardian owner snapshot has an invalid control identity")
542
+ return control.path
543
+ }
544
+
500
545
  /**
501
546
  * Stops accepting connections and closes every authority channel except the response caller.
502
547
  * @param {net.Socket} caller - Shutdown requester retained until it receives the response.
@@ -171,6 +171,37 @@ test("replacement staging rejects owner state published after prepare", async ()
171
171
  }
172
172
  })
173
173
 
174
+ test("retired owner replacement requires unchanged authority and the exact control path absent", async () => {
175
+ const fixture = await createGuardian()
176
+ const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
177
+ const controlPath = path.join(fixture.root, "rollbridge.sock")
178
+ const authority = {configDigest: "incumbent", runtime: null}
179
+ const nextAuthority = {configDigest: "candidate", runtime: null}
180
+ const snapshot = {activeReleaseId: "v1", control: {path: controlPath}}
181
+
182
+ try {
183
+ await fixture.client.publishOwnerState({authority, snapshot})
184
+ await candidate.connect()
185
+ const changed = await candidate.prepareOwnerReplacement(authority, nextAuthority)
186
+
187
+ await candidate.stageOwnerReplacement(changed.replacementId, {authority: nextAuthority, snapshot})
188
+ await assert.rejects(() => candidate.commitRetiredOwnerReplacement(changed.replacementId), /unchanged owner authority/)
189
+ await candidate.abortOwnerReplacement(changed.replacementId)
190
+
191
+ const occupied = await candidate.prepareOwnerReplacement(authority, authority)
192
+
193
+ await candidate.stageOwnerReplacement(occupied.replacementId, {authority, snapshot})
194
+ await fs.writeFile(controlPath, "occupied\n")
195
+ await assert.rejects(() => candidate.commitRetiredOwnerReplacement(occupied.replacementId), /control socket .* still exists/)
196
+ await candidate.abortOwnerReplacement(occupied.replacementId)
197
+ await fixture.client.shutdown()
198
+ await fixture.client.guardianExit()
199
+ } finally {
200
+ candidate.disconnect()
201
+ await cleanupGuardian(fixture)
202
+ }
203
+ })
204
+
174
205
  test("first upgrade migrates a real pre-split guardian without replacing its owned process", async () => {
175
206
  const fixture = await createLegacyGuardian()
176
207
  const processDefinition = definition("legacy-worker")
@@ -9,6 +9,7 @@ import os from "node:os"
9
9
  import path from "node:path"
10
10
  import test from "node:test"
11
11
  import {fileURLToPath} from "node:url"
12
+ import {normalizeConfig} from "../src/config.js"
12
13
  import {sendControlCommand} from "../src/control-client.js"
13
14
  import RollbridgeDaemon from "../src/daemon.js"
14
15
  import GuardianClient from "../src/guardian-client.js"
@@ -21,6 +22,79 @@ const serviceAppPath = path.join(currentDir, "fixtures", "service-app.js")
21
22
  /** @typedef {import("../src/daemon.js").DaemonStatus} DaemonStatus */
22
23
  /** @typedef {DaemonStatus & {recovery: {configDigest: string}}} RecoveryState */
23
24
 
25
+ test("external owner retirement releases guardian authority without losing its generation", async () => {
26
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-recovery-"))
27
+ const socketPath = path.join(root, "rollbridge.sock")
28
+ const statePath = path.join(root, "rollbridge.state.json")
29
+ const v1Path = path.join(root, "v1")
30
+ const v2Path = path.join(root, "v2")
31
+ const config = normalizeConfig({
32
+ application: "owner-retirement-recovery-test",
33
+ control: {path: socketPath},
34
+ ownerRecovery: {reconnectGraceMs: 50},
35
+ processes: [
36
+ {
37
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
38
+ id: "worker",
39
+ lifecycle: {drainCommand: "printf started > \"$ROLLBRIDGE_RELEASE_PATH/drain-started\"; while [ ! -f \"$ROLLBRIDGE_RELEASE_PATH/drained\" ]; do sleep 0.01; done", drainTimeoutMs: 3000},
40
+ nonBlockingDrain: true,
41
+ policy: "companion"
42
+ },
43
+ {
44
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
45
+ health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
46
+ id: "web",
47
+ policy: "proxied",
48
+ port: {from: 0, to: 0}
49
+ }
50
+ ],
51
+ proxy: {forceStopTimeoutMs: 500, healthPath: "/ping", healthTimeoutMs: 3000, host: "127.0.0.1", port: 0},
52
+ statePath
53
+ })
54
+ const retired = new RollbridgeDaemon({config, logger: () => {}})
55
+ let replacement
56
+
57
+ try {
58
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
59
+ await retired.start()
60
+ await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
61
+ const before = retired.status()
62
+ const v1WorkerPid = releaseProcessPid(before, "v1", "worker")
63
+
64
+ replacement = new RollbridgeDaemon({config, logger: () => {}})
65
+ await replacement.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
66
+ const v2WorkerPid = releaseProcessPid(replacement.status(), "v2", "worker")
67
+
68
+ await retired.retireOwner({attestation: `sha256:${"a".repeat(64)}`})
69
+ await replacement.start({reportOrphans: false})
70
+ const recovered = replacement.status()
71
+ const v1 = recovered.releases.find(({releaseId}) => releaseId === "v1")
72
+ const v2 = recovered.releases.find(({releaseId}) => releaseId === "v2")
73
+
74
+ assert.equal(recovered.activeReleaseId, "v2", "the prestarted candidate must remain active")
75
+ assert.deepEqual(recovered.releaseReferences.sort((a, b) => a.releaseId.localeCompare(b.releaseId)), [
76
+ {releaseId: "v1", releasePath: v1Path},
77
+ {releaseId: "v2", releasePath: v2Path}
78
+ ])
79
+ assert.equal(v1?.state, "draining")
80
+ assert.equal(v1?.processes.find(({id}) => id === "worker")?.pid, v1WorkerPid)
81
+ assert.equal(v1?.processes.find(({id}) => id === "worker")?.state, "quiesced")
82
+ assert.equal(v2?.state, "active")
83
+ assert.equal(v2?.processes.find(({id}) => id === "worker")?.pid, v2WorkerPid)
84
+ assert.equal(v2?.processes.find(({id}) => id === "worker")?.state, "running")
85
+ await waitForFile(path.join(v1Path, "drain-started"), 1000)
86
+ await fs.writeFile(path.join(v1Path, "drained"), "done\n")
87
+ await waitForProcessExit(v1WorkerPid, 1000)
88
+ assert.equal(isAlive(v2WorkerPid), true, "the active candidate worker must remain usable while the old generation drains")
89
+ } finally {
90
+ await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "drained"), "done\n").catch(() => {})))
91
+ await replacement?.shutdown().catch(() => {})
92
+ retired.guardian?.disconnect()
93
+ await stopFixtureGuardian(statePath)
94
+ await fs.rm(root, {force: true, recursive: true})
95
+ }
96
+ })
97
+
24
98
  test("replacement owner reconstructs one active and two draining generations after abrupt daemon exit", async () => {
25
99
  const fixture = await createFixture()
26
100
  let owner = spawnDaemon(fixture.configPath)
@@ -687,16 +761,19 @@ async function waitForState(statePath, predicate) {
687
761
 
688
762
  /**
689
763
  * @param {string} filePath - File whose creation is the transaction-boundary signal.
764
+ * @param {number} [timeoutMs] - Optional bounded wait.
690
765
  * @returns {Promise<void>} Resolves when the file exists.
691
766
  */
692
- async function waitForFile(filePath) {
767
+ async function waitForFile(filePath, timeoutMs) {
693
768
  try {
694
769
  await fs.access(filePath)
695
770
  return
696
771
  } catch (error) {
697
772
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
698
773
  }
699
- const watcher = fs.watch(path.dirname(filePath))
774
+ const controller = timeoutMs === undefined ? undefined : new AbortController()
775
+ const timer = timeoutMs === undefined ? undefined : setTimeout(() => controller?.abort(), timeoutMs)
776
+ const watcher = fs.watch(path.dirname(filePath), {signal: controller?.signal})
700
777
 
701
778
  try {
702
779
  for await (const change of watcher) {
@@ -708,11 +785,26 @@ async function waitForFile(filePath) {
708
785
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
709
786
  }
710
787
  }
788
+ } catch (error) {
789
+ if (error && typeof error === "object" && "name" in error && error.name === "AbortError") throw new Error(`Timed out waiting for ${filePath}`, {cause: error})
790
+ throw error
711
791
  } finally {
792
+ clearTimeout(timer)
712
793
  await watcher.return?.()
713
794
  }
714
795
  }
715
796
 
797
+ /**
798
+ * @param {number} pid - Exact fixture process.
799
+ * @param {number} timeoutMs - Bounded exit wait.
800
+ */
801
+ async function waitForProcessExit(pid, timeoutMs) {
802
+ const deadline = Date.now() + timeoutMs
803
+
804
+ while (isAlive(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10))
805
+ assert.equal(isAlive(pid), false, `process ${pid} did not exit within ${timeoutMs}ms`)
806
+ }
807
+
716
808
  /**
717
809
  * Opens a live WebSocket through the fixture proxy.
718
810
  * @param {number} port - Proxy port.
@@ -9,8 +9,9 @@ import os from "node:os"
9
9
  import path from "node:path"
10
10
  import test from "node:test"
11
11
  import {fileURLToPath} from "node:url"
12
+ import {normalizeConfig} from "../src/config.js"
12
13
  import {sendControlCommand} from "../src/control-client.js"
13
- import {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
14
+ import RollbridgeDaemon, {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
14
15
  import GuardianClient from "../src/guardian-client.js"
15
16
  import {findAvailablePort} from "../src/port-allocator.js"
16
17
 
@@ -248,6 +249,50 @@ test("ensure-daemon atomically replaces incompatible config, socket, and package
248
249
  }
249
250
  })
250
251
 
252
+ test("same-authority replacement commits after a retired incumbent already removed its control socket", async () => {
253
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-retired-control-"))
254
+ const socketPath = path.join(root, "rollbridge.sock")
255
+ const statePath = path.join(root, "state.json")
256
+ const releasePath = path.join(root, "v1")
257
+ const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath}))
258
+ const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
259
+ let replacement
260
+
261
+ try {
262
+ await fs.mkdir(releasePath)
263
+ await makeFifo(path.join(releasePath, "worker.fifo"))
264
+ await owner.start()
265
+ await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
266
+ await Promise.all([...owner.releases.values()].map((release) => release.quiesce()))
267
+ await owner.closeServer(owner.controlServer)
268
+ await owner.removeControlSocket()
269
+ await owner.closeServer(owner.proxyServer)
270
+ const retired = owner.status()
271
+ const processState = retired.releases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
272
+
273
+ assert.deepEqual(processState?.map(({state}) => state), ["quiesced", "quiesced"])
274
+ assert.equal((await owner.guardian?.replacementStatus())?.ownerClaimed, true)
275
+ await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
276
+
277
+ replacement = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
278
+ await replacement.replaceIncompatibleOwner()
279
+ const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
280
+ const recoveredReleases = /** @type {{processes: {id: string, pid?: number, state: string}[]}[]} */ (recovered.releases)
281
+
282
+ assert.equal(recovered.activeReleaseId, "v1")
283
+ assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
284
+ assert.deepEqual(recoveredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state})), processState)
285
+ } finally {
286
+ const shutdown = replacement?.controlCommandsReady ? replacement.shutdown().catch(() => {}) : owner.shutdown().catch(() => {})
287
+
288
+ await Promise.all([fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}), shutdown])
289
+ owner.guardian?.disconnect()
290
+ replacement?.guardian?.disconnect()
291
+ await stopGuardian(statePath)
292
+ await fs.rm(root, {force: true, recursive: true})
293
+ }
294
+ })
295
+
251
296
  test("replacement refuses to overwrite an unrelated live final control socket and preserves the owner", async () => {
252
297
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-fence-"))
253
298
  const oldSocketPath = path.join(root, "old.sock")