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.
@@ -13,6 +13,7 @@ import {normalizeConfig} from "../src/config.js"
13
13
  import {sendControlCommand} from "../src/control-client.js"
14
14
  import RollbridgeDaemon from "../src/daemon.js"
15
15
  import GuardianClient from "../src/guardian-client.js"
16
+ import {isProcessRunning, waitForProcessExit} from "./support/process.js"
16
17
 
17
18
  const currentDir = path.dirname(fileURLToPath(import.meta.url))
18
19
  const binPath = path.join(currentDir, "..", "bin", "rollbridge")
@@ -20,7 +21,7 @@ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
20
21
  const serviceAppPath = path.join(currentDir, "fixtures", "service-app.js")
21
22
 
22
23
  /** @typedef {import("../src/daemon.js").DaemonStatus} DaemonStatus */
23
- /** @typedef {DaemonStatus & {recovery: {configDigest: string}, singletonReleaseIds?: Record<string, string>}} RecoveryState */
24
+ /** @typedef {DaemonStatus & {recovery: {configDigest: string}, serviceReleaseIds?: Record<string, string>, singletonReleaseIds?: Record<string, string>}} RecoveryState */
24
25
 
25
26
  test("external owner retirement releases guardian authority without losing its generation", async () => {
26
27
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-recovery-"))
@@ -85,7 +86,7 @@ test("external owner retirement releases guardian authority without losing its g
85
86
  await waitForFile(path.join(v1Path, "drain-started"), 1000)
86
87
  await fs.writeFile(path.join(v1Path, "drained"), "done\n")
87
88
  await waitForProcessExit(v1WorkerPid, 1000)
88
- assert.equal(isAlive(v2WorkerPid), true, "the active candidate worker must remain usable while the old generation drains")
89
+ assert.equal(isProcessRunning(v2WorkerPid), true, "the active candidate worker must remain usable while the old generation drains")
89
90
  } finally {
90
91
  await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "drained"), "done\n").catch(() => {})))
91
92
  await replacement?.shutdown().catch(() => {})
@@ -134,6 +135,12 @@ test("exact bootstrap restores the committed generation after external owner ret
134
135
  const recoveredService = recovered.services.get("beacon")
135
136
 
136
137
  assert.ok(recoveredCandidate && recoveredService && recovered.singletons.get("singleton"))
138
+ recovered.serviceReleaseIds.set("beacon", "v1")
139
+ assert.throws(
140
+ () => recovered?.assertCommittedBootstrapRecoveryReady(),
141
+ /service beacon belongs to retained release v1/
142
+ )
143
+ recovered.serviceReleaseIds.set("beacon", "v2")
137
144
  const activateGeneration = recoveredCandidate.activateGeneration.bind(recoveredCandidate)
138
145
  const startService = recoveredService.start.bind(recoveredService)
139
146
  const replaceSingletons = recovered.replaceSingletons.bind(recovered)
@@ -158,7 +165,7 @@ test("exact bootstrap restores the committed generation after external owner ret
158
165
  assert.equal(active.generationTransition?.phase, "committed")
159
166
  assert.equal(active.releases.find(({releaseId}) => releaseId === "v1")?.state, "draining")
160
167
  assert.equal(releaseProcessPid(active, "v1", "worker"), v1WorkerPid)
161
- assert.equal(isAlive(v1WorkerPid), true, "the retained previous generation must keep draining")
168
+ assert.equal(isProcessRunning(v1WorkerPid), true, "the retained previous generation must keep draining")
162
169
  assert.equal(active.releases.find(({releaseId}) => releaseId === "v2")?.state, "active")
163
170
  assert.ok(active.releases.find(({releaseId}) => releaseId === "v2")?.processes.every(({pid, state}) => typeof pid === "number" && state === "running"))
164
171
  assert.ok(active.services.every(({process}) => typeof process.pid === "number" && process.state === "running"))
@@ -180,6 +187,68 @@ test("exact bootstrap restores the committed generation after external owner ret
180
187
  }
181
188
  })
182
189
 
190
+ test("exact bootstrap restores a committed generation after its previous release was pruned", async () => {
191
+ const fixture = await createFixture()
192
+
193
+ fixture.config.processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
194
+ .filter((processConfig) => processConfig.id !== "beacon" && processConfig.id !== "singleton")
195
+ const worker = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
196
+ .find((processConfig) => processConfig.id === "worker")
197
+
198
+ assert.ok(worker)
199
+ const workerLifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (worker.lifecycle)
200
+
201
+ workerLifecycle.drainTimeoutMs = 500
202
+ fixture.config.releaseRetention = {keep: 0, maxAgeMs: 0}
203
+ await writeConfig(fixture.configPath, fixture.config)
204
+ const config = normalizeConfig(fixture.config, fixture.configPath)
205
+ const retired = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
206
+ /** @type {RollbridgeDaemon | undefined} */
207
+ let recovered
208
+ const v1Path = await prepareRelease(fixture.root, "v1")
209
+ const v2Path = await prepareRelease(fixture.root, "v2")
210
+
211
+ try {
212
+ await retired.start()
213
+ await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
214
+ await retired.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
215
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
216
+ await waitForState(fixture.statePath, (state) => !state.releases.some(({releaseId}) => releaseId === "v1"), AbortSignal.timeout(5000))
217
+ const committed = retired.status()
218
+ const retiredPids = committed.releases.find(({releaseId}) => releaseId === "v2")?.processes
219
+ .map(({pid}) => pid)
220
+ .filter((pid) => typeof pid === "number") || []
221
+
222
+ assert.equal(committed.releases.some(({releaseId}) => releaseId === "v1"), false)
223
+ await retired.retireOwner({attestation: `sha256:${"d".repeat(64)}`})
224
+ await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
225
+ await Promise.all(retiredPids.map((pid) => waitForProcessExit(pid, 3000)))
226
+
227
+ recovered = new RollbridgeDaemon({
228
+ bootstrap: {releaseId: "v2", releasePath: v2Path, revision: "v2"},
229
+ config,
230
+ configPath: fixture.configPath,
231
+ logger: () => {}
232
+ })
233
+ await recovered.start({exposeControl: false})
234
+ await recovered.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
235
+
236
+ assert.equal(recovered.status().activeReleaseId, "v2")
237
+ assert.equal(recovered.status().generationTransition?.phase, "committed")
238
+ } finally {
239
+ if (recovered) {
240
+ const activeRecovery = recovered.status().activeReleaseId === "v2"
241
+ const shutdown = recovered.shutdown()
242
+
243
+ if (activeRecovery) await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined)
244
+ await shutdown.catch(() => undefined)
245
+ }
246
+ retired.guardian?.disconnect()
247
+ await stopFixtureGuardian(fixture.statePath)
248
+ await fs.rm(fixture.root, {force: true, recursive: true})
249
+ }
250
+ })
251
+
183
252
  test("journaled committed bootstrap recovery resumes after a restart begins", async () => {
184
253
  const fixture = await createFixture()
185
254
  const config = normalizeConfig(fixture.config, fixture.configPath)
@@ -237,7 +306,7 @@ test("journaled committed bootstrap recovery resumes after a restart begins", as
237
306
  assert.deepEqual(active.services.map(({process}) => process.pid), restartedServicePids)
238
307
  assert.ok(active.singletons.every(({process}) => typeof process.pid === "number" && process.state === "running"))
239
308
  assert.equal(releaseProcessPid(active, "v1", "worker"), v1WorkerPid)
240
- assert.equal(isAlive(v1WorkerPid), true)
309
+ assert.equal(isProcessRunning(v1WorkerPid), true)
241
310
  assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2", "retire:v2", "activate:v2"])
242
311
  } finally {
243
312
  if (recovered) {
@@ -320,7 +389,7 @@ test("committed bootstrap tuple mismatches fail closed without singletons", asyn
320
389
  assert.equal(preserved.generationTransition?.phase, "committed")
321
390
  assert.equal(preserved.releases.find(({releaseId}) => releaseId === "v2")?.state, "draining")
322
391
  assert.equal(releaseProcessPid(preserved, "v1", "worker"), v1WorkerPid)
323
- assert.equal(isAlive(v1WorkerPid), true)
392
+ assert.equal(isProcessRunning(v1WorkerPid), true)
324
393
  assert.equal(preserved.releases.some(({releaseId}) => releaseId === "wrong"), false)
325
394
 
326
395
  await owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
@@ -425,6 +494,260 @@ test("replacement owner reconstructs one active and two draining generations aft
425
494
  }
426
495
  })
427
496
 
497
+ test("guardian restarts an abruptly exited daemon without replacing managed processes", async () => {
498
+ const fixture = await createFixture()
499
+ const owner = spawnDaemon(fixture.configPath)
500
+ let recoveredDaemonPid
501
+ let workerPid
502
+
503
+ try {
504
+ await waitForLog(owner, "control socket listening")
505
+ const releasePath = await prepareRelease(fixture.root, "v1")
506
+
507
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath})
508
+ const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
509
+
510
+ workerPid = releaseProcessPid(before, "v1", "worker")
511
+ const recoveredListenerLog = waitForLog(owner, "control socket listening", {allowChildExit: true})
512
+
513
+ owner.kill("SIGKILL")
514
+ await once(owner, "exit")
515
+ await recoveredListenerLog
516
+ const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
517
+
518
+ recoveredDaemonPid = recovered.daemonPid
519
+ assert.notEqual(recovered.daemonPid, before.daemonPid)
520
+ assert.equal(recovered.activeReleaseId, "v1")
521
+ assert.equal(releaseProcessPid(recovered, "v1", "worker"), workerPid)
522
+
523
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
524
+
525
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
526
+ await shutdown
527
+ } finally {
528
+ await killChild(owner)
529
+ if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
530
+ if (workerPid) {
531
+ try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact managed group already exited. */ }
532
+ }
533
+ await stopFixtureGuardian(fixture.statePath)
534
+ await fs.rm(fixture.root, {force: true, recursive: true})
535
+ }
536
+ })
537
+
538
+ test("guardian recovers a persistent service after its final active release stops", async () => {
539
+ const fixture = await createFixture()
540
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
541
+ const jobs = processes.find((processConfig) => processConfig.id === "jobs")
542
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
543
+
544
+ assert.ok(jobs && worker)
545
+ jobs.port = {from: 17000, to: 17001}
546
+ worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
547
+ fixture.config.processes = processes.filter((processConfig) => processConfig.id !== "singleton")
548
+ await writeConfig(fixture.configPath, fixture.config)
549
+ const owner = spawnDaemon(fixture.configPath)
550
+ let recoveredDaemonPid
551
+
552
+ try {
553
+ await waitForLog(owner, "control socket listening")
554
+ const releasePath = await prepareRelease(fixture.root, "v1")
555
+
556
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath})
557
+ await sendControlCommand({command: {command: "stop", releaseId: "v1"}, path: fixture.socketPath})
558
+ const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
559
+ const servicePid = before.services[0]?.process.pid
560
+
561
+ assert.equal(before.activeReleaseId, null)
562
+ assert.equal(typeof servicePid, "number")
563
+ const persisted = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(fixture.statePath, "utf8")))
564
+
565
+ assert.equal(persisted.serviceReleaseIds?.beacon, "v1")
566
+ const recoveredListenerLog = waitForLog(owner, "control socket listening", {allowChildExit: true})
567
+
568
+ owner.kill("SIGKILL")
569
+ await once(owner, "exit")
570
+ await recoveredListenerLog
571
+ const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
572
+
573
+ recoveredDaemonPid = recovered.daemonPid
574
+ assert.equal(recovered.activeReleaseId, null)
575
+ assert.equal(recovered.services[0]?.process.pid, servicePid)
576
+ assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
577
+ const nextReleasePath = await prepareRelease(fixture.root, "v2")
578
+
579
+ await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: nextReleasePath, revision: "v2"}, path: fixture.socketPath})
580
+ const finalReleasePath = await prepareRelease(fixture.root, "v3")
581
+
582
+ await sendControlCommand({command: {command: "deploy", releaseId: "v3", releasePath: finalReleasePath, revision: "v3"}, path: fixture.socketPath})
583
+ assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v3")
584
+ await sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
585
+ } finally {
586
+ await killChild(owner)
587
+ if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
588
+ await stopFixtureGuardian(fixture.statePath)
589
+ await fs.rm(fixture.root, {force: true, recursive: true})
590
+ }
591
+ })
592
+
593
+ test("owner state omits a new persistent service until its defining release is retained", async () => {
594
+ const fixture = await createFixture()
595
+ const config = normalizeConfig(fixture.config, fixture.configPath)
596
+ const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
597
+ const releasePath = await prepareRelease(fixture.root, "v1", {holdJobsBind: true})
598
+ /** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
599
+ let deploy
600
+
601
+ try {
602
+ await owner.start()
603
+ deploy = owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
604
+ await waitForFile(path.join(releasePath, "jobs.bind-waiting"), 3000)
605
+ await owner.persistState({throwOnError: true})
606
+ const persisted = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(fixture.statePath, "utf8")))
607
+ const guardianState = /** @type {Record<string, import("../src/json.js").JsonValue> | undefined} */ (await owner.guardian?.ownerState())
608
+ const guardianSnapshot = /** @type {RecoveryState | undefined} */ (guardianState?.snapshot)
609
+
610
+ assert.deepEqual(persisted.releases, [])
611
+ assert.deepEqual(persisted.services, [])
612
+ assert.deepEqual(persisted.serviceReleaseIds, {})
613
+ assert.deepEqual(guardianSnapshot?.releases, [])
614
+ assert.deepEqual(guardianSnapshot?.services, [])
615
+ assert.deepEqual(guardianState?.serviceReleaseIds, {})
616
+
617
+ await fs.writeFile(path.join(releasePath, "jobs.bind"), "ready\n")
618
+ await deploy
619
+ assert.equal(owner.status().services.find(({id}) => id === "beacon")?.process.state, "running")
620
+ assert.equal(owner.serviceReleaseIds.get("beacon"), "v1")
621
+ } finally {
622
+ await fs.writeFile(path.join(releasePath, "jobs.bind"), "ready\n").catch(() => {})
623
+ await deploy?.catch(() => {})
624
+ await Promise.all([
625
+ fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}),
626
+ owner.shutdown().catch(() => {})
627
+ ])
628
+ owner.guardian?.disconnect()
629
+ await stopFixtureGuardian(fixture.statePath)
630
+ await fs.rm(fixture.root, {force: true, recursive: true})
631
+ }
632
+ })
633
+
634
+ test("owner recovery accepts released format-2 state without journal revision or service owner metadata", async () => {
635
+ const fixture = await createFixture()
636
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
637
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
638
+
639
+ assert.ok(worker)
640
+ worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
641
+ await writeConfig(fixture.configPath, fixture.config)
642
+ const config = normalizeConfig(fixture.config, fixture.configPath)
643
+ const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
644
+ const releasePath = await prepareRelease(fixture.root, "v1")
645
+ /** @type {RollbridgeDaemon | undefined} */
646
+ let recovered
647
+
648
+ try {
649
+ await owner.start()
650
+ await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
651
+ if (owner.pendingWrite) await owner.pendingWrite
652
+ const publicState = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
653
+ const privateOwnerState = owner.transferableOwnerState()
654
+ const privateSnapshot = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (privateOwnerState.snapshot)
655
+ const privateTransition = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (privateSnapshot.generationTransition)
656
+
657
+ delete publicState.generationTransition.journalRevision
658
+ delete publicState.serviceReleaseIds
659
+ delete privateTransition.journalRevision
660
+ delete privateOwnerState.serviceReleaseIds
661
+ await owner.guardian?.publishOwnerState(privateOwnerState)
662
+ await fs.writeFile(fixture.statePath, `${JSON.stringify(publicState, null, 2)}\n`)
663
+ await owner.retireCommittedOwner(undefined)
664
+ owner.guardian?.disconnect()
665
+
666
+ recovered = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
667
+ await recovered.start()
668
+
669
+ assert.equal(recovered.status().activeReleaseId, "v1")
670
+ assert.equal(recovered.status().generationTransition?.journalRevision, undefined)
671
+ assert.equal(recovered.serviceReleaseIds.get("beacon"), "v1")
672
+ } finally {
673
+ if (recovered) await recovered.shutdown().catch(() => {})
674
+ else await owner.shutdown().catch(() => {})
675
+ owner.guardian?.disconnect()
676
+ await stopFixtureGuardian(fixture.statePath)
677
+ await fs.rm(fixture.root, {force: true, recursive: true})
678
+ }
679
+ })
680
+
681
+ test("guardian recovery becomes ready before replaying a gated generation hook", async () => {
682
+ const fixture = await createFixture()
683
+ const retirementGatePath = path.join(fixture.root, "retirement.allow")
684
+ const retirementWaitingPath = path.join(fixture.root, "retirement.waiting")
685
+ const daemonPidPath = path.join(fixture.root, "daemon.pid")
686
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
687
+ const jobs = processes.find((processConfig) => processConfig.id === "jobs")
688
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
689
+ const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs?.lifecycle)
690
+
691
+ assert.ok(jobs && worker)
692
+ jobs.gracefulStopMs = 5000
693
+ lifecycle.quietCommand = `printf 'waiting\n' >> ${JSON.stringify(retirementWaitingPath)}; while [ ! -f ${JSON.stringify(retirementGatePath)} ]; do sleep 0.02; done; printf 'retire:%s\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(fixture.lifecycleLogPath)}`
694
+ worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
695
+ await writeConfig(fixture.configPath, fixture.config)
696
+ const owner = spawnDaemon(fixture.configPath, undefined, {daemonPidPath, startupTimeoutMs: 3000})
697
+ const v1Path = await prepareRelease(fixture.root, "v1")
698
+ const v2Path = await prepareRelease(fixture.root, "v2")
699
+ let recoveredDaemonPid
700
+
701
+ try {
702
+ await waitForLog(owner, "control socket listening")
703
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
704
+ const interruptedDeploy = sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
705
+
706
+ void interruptedDeploy.catch(() => {})
707
+ await waitForFile(retirementWaitingPath)
708
+ await fs.rm(daemonPidPath, {force: true})
709
+ const recoveredListenerLog = waitForLog(owner, "control socket listening", {allowChildExit: true})
710
+
711
+ owner.kill("SIGKILL")
712
+ await once(owner, "exit")
713
+ await recoveredListenerLog
714
+ await waitForFile(daemonPidPath, 3000)
715
+ recoveredDaemonPid = Number((await fs.readFile(daemonPidPath, "utf8")).trim())
716
+ const recovering = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
717
+ const guardianPid = JSON.parse(await fs.readFile(fixture.statePath, "utf8")).recovery?.guardian?.pid
718
+
719
+ assert.equal(recovering.daemonPid, recoveredDaemonPid)
720
+ assert.equal(recovering.ownerRecovery?.ready, true)
721
+ assert.equal(recovering.generationTransition?.phase, "retiring_previous")
722
+ assert.equal(typeof guardianPid, "number")
723
+ await assert.rejects(
724
+ sendControlCommand({command: {command: "stop", releaseId: "v1"}, path: fixture.socketPath}),
725
+ /Another owner mutation/
726
+ )
727
+ await assert.rejects(
728
+ sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath}),
729
+ /Cannot shut down while generation transition recovery is in progress/
730
+ )
731
+ await assert.rejects(
732
+ sendControlCommand({command: {attestation: `sha256:${"a".repeat(64)}`, command: "retire-owner"}, path: fixture.socketPath}),
733
+ /Cannot retire owner while generation transition recovery is in progress/
734
+ )
735
+
736
+ process.kill(recoveredDaemonPid, "SIGTERM")
737
+ await fs.writeFile(retirementGatePath, "release retirement\n")
738
+ await waitForProcessExit(recoveredDaemonPid, 5000)
739
+ await waitForProcessExit(guardianPid, 5000)
740
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v1", "activate:v2", "retire:v2"])
741
+ } finally {
742
+ await fs.writeFile(retirementGatePath, "release retirement\n").catch(() => undefined)
743
+ await sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath}).catch(() => undefined)
744
+ await killChild(owner)
745
+ if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
746
+ await stopFixtureGuardian(fixture.statePath)
747
+ await fs.rm(fixture.root, {force: true, recursive: true})
748
+ }
749
+ })
750
+
428
751
  test("owner recovery preserves a failed generation transition without firing hooks until exact resume", async () => {
429
752
  const fixture = await createFixture({activationFailureRelease: "v2"})
430
753
  let owner = spawnDaemon(fixture.configPath)
@@ -483,10 +806,15 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
483
806
  const ambiguous = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
484
807
 
485
808
  delete ambiguous.generationTransition.error
809
+ ambiguous.generationTransition.journalRevision += 1
486
810
  await fs.writeFile(fixture.statePath, `${JSON.stringify(ambiguous, null, 2)}\n`)
487
811
  await fs.writeFile(fixture.activationGatePath, "allow\n")
488
812
  owner = spawnDaemon(fixture.configPath)
813
+ const recoverySettled = waitForLog(owner, "release generation transition recovery settled")
814
+
489
815
  await waitForLog(owner, "control socket listening")
816
+ await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed", AbortSignal.timeout(5000))
817
+ await recoverySettled
490
818
 
491
819
  const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
492
820
 
@@ -504,8 +832,14 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
504
832
  }
505
833
  })
506
834
 
507
- test("owner recovery preserves exact release definitions across a candidate config authority change", async () => {
835
+ test("owner recovery preserves complete private transition authority across a candidate config change", async () => {
508
836
  const fixture = await createFixture()
837
+ const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
838
+ const initialWorker = initialProcesses.find((processConfig) => processConfig.id === "worker")
839
+
840
+ assert.ok(initialWorker)
841
+ initialWorker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
842
+ await writeConfig(fixture.configPath, fixture.config)
509
843
  const initialConfig = normalizeConfig(fixture.config, fixture.configPath)
510
844
  const owner = new RollbridgeDaemon({config: initialConfig, configPath: fixture.configPath, logger: () => {}})
511
845
  /** @type {RollbridgeDaemon | undefined} */
@@ -534,6 +868,12 @@ test("owner recovery preserves exact release definitions across a candidate conf
534
868
 
535
869
  await owner.retireCommittedOwner(undefined)
536
870
  owner.guardian?.disconnect()
871
+ const partialPublicState = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
872
+
873
+ partialPublicState.generationTransition.phase = "committed"
874
+ partialPublicState.releases = partialPublicState.releases.filter((/** @type {{releaseId: string}} */ release) => release.releaseId !== "v2")
875
+ partialPublicState.releaseReferences = partialPublicState.releaseReferences.filter((/** @type {{releaseId: string}} */ release) => release.releaseId !== "v2")
876
+ await fs.writeFile(fixture.statePath, `${JSON.stringify(partialPublicState, null, 2)}\n`)
537
877
 
538
878
  recovered = new RollbridgeDaemon({config: normalizeConfig(changedConfig, fixture.configPath), configPath: fixture.configPath, logger: () => {}})
539
879
  await recovered.start()
@@ -542,12 +882,7 @@ test("owner recovery preserves exact release definitions across a candidate conf
542
882
  assert.equal(recovered.status().generationTransition?.phase, "committed")
543
883
  assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
544
884
  } finally {
545
- if (recovered) {
546
- const shutdown = recovered.shutdown()
547
-
548
- await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)))
549
- await shutdown.catch(() => {})
550
- }
885
+ if (recovered) await recovered.shutdown().catch(() => {})
551
886
  await stopFixtureGuardian(fixture.statePath)
552
887
  await fs.rm(fixture.root, {force: true, recursive: true})
553
888
  }
@@ -583,6 +918,7 @@ test("owner recovery reconstructs a stopped release that still owns a committed-
583
918
 
584
919
  assert.equal(pending.activeReleaseId, "v2")
585
920
  assert.equal(pending.generationTransition?.phase, "committed_pending")
921
+ assert.equal(pending.serviceReleaseIds?.beacon, "v2")
586
922
  assert.equal(pending.singletonReleaseIds?.singleton, "v1")
587
923
  const stoppedRelease = pending.releases.find((release) => release.releaseId === "v1" && release.state === "stopped")
588
924
 
@@ -622,6 +958,9 @@ test("owner recovery reconstructs a stopped release that still owns a committed-
622
958
 
623
959
  test("owner recovery uses the owning release singleton definition during a committed-pending config change", async () => {
624
960
  const fixture = await createFixture()
961
+ const fixtureProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
962
+
963
+ fixture.config.processes = fixtureProcesses.filter((processConfig) => processConfig.id !== "beacon")
625
964
  const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
626
965
  const initialSingleton = initialProcesses.find((processConfig) => processConfig.id === "singleton")
627
966
 
@@ -688,6 +1027,67 @@ test("owner recovery uses the owning release singleton definition during a commi
688
1027
  }
689
1028
  })
690
1029
 
1030
+ test("owner recovery retains a stopped previous release until committed-pending singleton work completes", async () => {
1031
+ const fixture = await createFixture()
1032
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
1033
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
1034
+
1035
+ assert.ok(worker)
1036
+ worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
1037
+ await writeConfig(fixture.configPath, fixture.config)
1038
+ const config = normalizeConfig(fixture.config, fixture.configPath)
1039
+ const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
1040
+ const replaceSingletons = owner.replaceSingletons.bind(owner)
1041
+ /** @type {() => void} */
1042
+ let continueReplacement = () => {}
1043
+ /** @type {() => void} */
1044
+ let markReplacementComplete = () => {}
1045
+ const replacementGate = new Promise((resolve) => { continueReplacement = () => resolve(undefined) })
1046
+ const replacementComplete = new Promise((resolve) => { markReplacementComplete = () => resolve(undefined) })
1047
+ const v1Path = await prepareRelease(fixture.root, "v1")
1048
+ const v2Path = await prepareRelease(fixture.root, "v2")
1049
+ /** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
1050
+ let deployPromise
1051
+ /** @type {RollbridgeDaemon | undefined} */
1052
+ let recovered
1053
+
1054
+ owner.replaceSingletons = async (release) => {
1055
+ await replaceSingletons(release)
1056
+ if (release.releaseId === "v2") {
1057
+ markReplacementComplete()
1058
+ await replacementGate
1059
+ }
1060
+ }
1061
+
1062
+ try {
1063
+ await owner.start()
1064
+ await owner.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
1065
+ deployPromise = owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
1066
+ void deployPromise.catch(() => {})
1067
+ await replacementComplete
1068
+ const pending = await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed_pending" && state.releases?.some((release) => release.releaseId === "v1" && release.state === "stopped"), AbortSignal.timeout(5000))
1069
+
1070
+ assert.deepEqual(pending.releaseReferences.map((reference) => reference.releaseId), ["v1", "v2"])
1071
+ assert.equal(pending.singletonReleaseIds?.singleton, "v2")
1072
+ await owner.retireCommittedOwner(undefined)
1073
+ owner.guardian?.disconnect()
1074
+
1075
+ recovered = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
1076
+ await recovered.start()
1077
+
1078
+ assert.equal(recovered.status().generationTransition?.phase, "committed")
1079
+ assert.equal(recovered.status().activeReleaseId, "v2")
1080
+ } finally {
1081
+ continueReplacement()
1082
+ await deployPromise?.catch(() => {})
1083
+ if (recovered) await recovered.shutdown().catch(() => {})
1084
+ else await owner.shutdown().catch(() => {})
1085
+ owner.guardian?.disconnect()
1086
+ await stopFixtureGuardian(fixture.statePath)
1087
+ await fs.rm(fixture.root, {force: true, recursive: true})
1088
+ }
1089
+ })
1090
+
691
1091
  test("owner recovery replays ambiguous retirement with the previous release's exact definition", async () => {
692
1092
  const fixture = await createFixture()
693
1093
  const retirementGatePath = path.join(fixture.root, "retirement.allow")
@@ -722,8 +1122,11 @@ test("owner recovery replays ambiguous retirement with the previous release's ex
722
1122
  await fs.writeFile(retirementGatePath, "release retirement\n")
723
1123
 
724
1124
  owner = spawnDaemon(fixture.configPath)
1125
+ const recoverySettled = waitForLog(owner, "release generation transition recovery settled")
725
1126
 
726
1127
  await waitForLog(owner, "control socket listening")
1128
+ await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed", AbortSignal.timeout(5000))
1129
+ await recoverySettled
727
1130
  assert.equal((await fs.readFile(retirementWaitingPath, "utf8")).trim().split("\n").length, 2, "ambiguous retirement must replay exactly once")
728
1131
  assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v1", "activate:v2"])
729
1132
  assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2")
@@ -760,7 +1163,7 @@ test("owner recovery rejects config identity mismatch without changing the valid
760
1163
  const rejected = await runDaemon(fixture.configPath)
761
1164
 
762
1165
  assert.notEqual(rejected.code, 0)
763
- assert.match(rejected.output, /config identity does not match/)
1166
+ assert.match(rejected.output, /authority does not match/)
764
1167
  assert.equal(await fs.readFile(fixture.statePath, "utf8"), validState)
765
1168
 
766
1169
  await writeConfig(fixture.configPath, fixture.config)
@@ -819,9 +1222,10 @@ test("failed recovery bootstrap keeps the reconstructed active generation servin
819
1222
  }
820
1223
  })
821
1224
 
822
- test("owner recovery fails closed on a partial snapshot and preserves it for repair", async () => {
1225
+ test("owner recovery repairs a partial public snapshot from committed guardian state", async () => {
823
1226
  const fixture = await createFixture()
824
- let owner = spawnDaemon(fixture.configPath)
1227
+ const owner = spawnDaemon(fixture.configPath)
1228
+ let recoveredDaemonPid
825
1229
  let workerPid
826
1230
 
827
1231
  try {
@@ -838,21 +1242,24 @@ test("owner recovery fails closed on a partial snapshot and preserves it for rep
838
1242
  const partialState = {...validState, releases: []}
839
1243
  await fs.writeFile(fixture.statePath, `${JSON.stringify(partialState, null, 2)}\n`)
840
1244
 
841
- const rejected = await runDaemon(fixture.configPath)
1245
+ const repairedState = await waitForState(
1246
+ fixture.statePath,
1247
+ (state) => state.daemonPid !== validState.daemonPid && state.releases.some((release) => release.releaseId === "v1"),
1248
+ AbortSignal.timeout(5000)
1249
+ )
1250
+ recoveredDaemonPid = repairedState.daemonPid
1251
+ const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
842
1252
 
843
- assert.notEqual(rejected.code, 0)
844
- assert.match(rejected.output, /does not contain active release v1/)
845
- assert.deepEqual(JSON.parse(await fs.readFile(fixture.statePath, "utf8")), partialState)
1253
+ assert.equal(recovered.activeReleaseId, "v1")
1254
+ assert.equal(releaseProcessPid(recovered, "v1", "worker"), workerPid)
1255
+ assert.notDeepEqual(JSON.parse(await fs.readFile(fixture.statePath, "utf8")), partialState)
846
1256
 
847
- await fs.writeFile(fixture.statePath, `${JSON.stringify(validState, null, 2)}\n`)
848
- owner = spawnDaemon(fixture.configPath)
849
- await waitForLog(owner, "control socket listening")
850
1257
  const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
851
1258
  await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
852
1259
  await shutdown
853
- await once(owner, "exit")
854
1260
  } finally {
855
1261
  await killChild(owner)
1262
+ if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
856
1263
  if (workerPid) {
857
1264
  try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact group already exited. */ }
858
1265
  }
@@ -1014,6 +1421,75 @@ test("deploy rejects a live ownerRecovery mode change", async () => {
1014
1421
  }
1015
1422
  })
1016
1423
 
1424
+ test("deploy rejects a live activation lifecycle mode change", async () => {
1425
+ const fixture = await createFixture()
1426
+ const owner = spawnDaemon(fixture.configPath)
1427
+ const v1Path = await prepareRelease(fixture.root, "v1")
1428
+ const v2Path = await prepareRelease(fixture.root, "v2")
1429
+
1430
+ try {
1431
+ await waitForLog(owner, "control socket listening")
1432
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
1433
+ const changedConfig = structuredClone(fixture.config)
1434
+ const jobs = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (changedConfig.processes).find((processConfig) => processConfig.id === "jobs")
1435
+ const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs?.lifecycle)
1436
+
1437
+ delete lifecycle.activateCommand
1438
+ await writeConfig(fixture.configPath, changedConfig)
1439
+ await assert.rejects(
1440
+ sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
1441
+ /lifecycle\.activateCommand.*cannot be applied live/
1442
+ )
1443
+
1444
+ await writeConfig(fixture.configPath, fixture.config)
1445
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
1446
+
1447
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
1448
+ await shutdown
1449
+ } finally {
1450
+ await killChild(owner)
1451
+ await stopFixtureGuardian(fixture.statePath)
1452
+ await fs.rm(fixture.root, {force: true, recursive: true})
1453
+ }
1454
+ })
1455
+
1456
+ test("public state does not advance when private guardian publication fails", async () => {
1457
+ const fixture = await createFixture()
1458
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
1459
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
1460
+
1461
+ assert.ok(worker)
1462
+ worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
1463
+ await writeConfig(fixture.configPath, fixture.config)
1464
+ const config = normalizeConfig(fixture.config, fixture.configPath)
1465
+ const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
1466
+ const releasePath = await prepareRelease(fixture.root, "v1")
1467
+
1468
+ try {
1469
+ await owner.start()
1470
+ await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
1471
+ if (owner.pendingWrite) await owner.pendingWrite
1472
+ const before = await fs.readFile(fixture.statePath, "utf8")
1473
+ const publishOwnerState = owner.publishOwnerState.bind(owner)
1474
+
1475
+ owner.ownerReady = true
1476
+ owner.publishOwnerState = async () => { throw new Error("injected guardian publication failure") }
1477
+ const write = owner.persistState({throwOnError: true})
1478
+
1479
+ assert.ok(write)
1480
+ await assert.rejects(write, /injected guardian publication failure/)
1481
+ assert.equal(await fs.readFile(fixture.statePath, "utf8"), before)
1482
+ owner.publishOwnerState = publishOwnerState
1483
+ await owner.persistState({throwOnError: true})
1484
+ await owner.shutdown()
1485
+ } finally {
1486
+ owner.publishOwnerState = RollbridgeDaemon.prototype.publishOwnerState.bind(owner)
1487
+ await owner.shutdown().catch(() => {})
1488
+ await stopFixtureGuardian(fixture.statePath)
1489
+ await fs.rm(fixture.root, {force: true, recursive: true})
1490
+ }
1491
+ })
1492
+
1017
1493
  test("replacement removes only guardian-owned candidate inventory left before deploy commit", async () => {
1018
1494
  const fixture = await createFixture()
1019
1495
  let owner = spawnDaemon(fixture.configPath)
@@ -1056,7 +1532,7 @@ test("replacement removes only guardian-owned candidate inventory left before de
1056
1532
 
1057
1533
  assert.equal(recovered.activeReleaseId, "v1")
1058
1534
  assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
1059
- assert.equal(isAlive(candidatePid), false, "uncommitted candidate must be stopped before replacement becomes healthy")
1535
+ assert.equal(isProcessRunning(candidatePid), false, "uncommitted candidate must be stopped before replacement becomes healthy")
1060
1536
 
1061
1537
  await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
1062
1538
  assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2", "removed candidate keys must be reusable by a later valid deploy")
@@ -1067,7 +1543,7 @@ test("replacement removes only guardian-owned candidate inventory left before de
1067
1543
  await once(owner, "exit")
1068
1544
  } finally {
1069
1545
  await killChild(owner)
1070
- if (candidatePid && isAlive(candidatePid)) {
1546
+ if (candidatePid && isProcessRunning(candidatePid)) {
1071
1547
  try { process.kill(-candidatePid, "SIGKILL") } catch (_error) { /* Exact candidate group already exited. */ }
1072
1548
  }
1073
1549
  await stopFixtureGuardian(fixture.statePath)
@@ -1075,6 +1551,47 @@ test("replacement removes only guardian-owned candidate inventory left before de
1075
1551
  }
1076
1552
  })
1077
1553
 
1554
+ test("ensure-daemon replaces a same-authority owner whose control socket disappeared", async () => {
1555
+ const fixture = await createFixture()
1556
+ const runtimePath = path.join(fixture.root, "runtime")
1557
+ const daemonLogPath = path.join(fixture.root, "same-authority-replacement.log")
1558
+ const daemonPidPath = path.join(fixture.root, "same-authority-replacement.pid")
1559
+ const v1Path = await prepareRelease(fixture.root, "v1")
1560
+ const ensureArgs = [
1561
+ "ensure-daemon", "--config", fixture.configPath,
1562
+ "--daemon-log-path", daemonLogPath,
1563
+ "--daemon-pid-path", daemonPidPath,
1564
+ "--daemon-runtime-path", runtimePath,
1565
+ "--daemon-start-timeout-ms", "5000"
1566
+ ]
1567
+
1568
+ try {
1569
+ const first = await runCli(ensureArgs)
1570
+
1571
+ assert.equal(first.code, 0, first.output)
1572
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
1573
+ const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
1574
+
1575
+ await fs.rm(fixture.socketPath, {force: true})
1576
+ const replacement = await runCli(ensureArgs)
1577
+
1578
+ assert.equal(replacement.code, 0, `${replacement.output}\n${await fs.readFile(daemonLogPath, "utf8")}`)
1579
+ const after = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
1580
+
1581
+ assert.equal(after.activeReleaseId, "v1")
1582
+ assert.notEqual(after.daemonPid, before.daemonPid)
1583
+ await waitForProcessExit(before.daemonPid)
1584
+
1585
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
1586
+
1587
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
1588
+ await shutdown
1589
+ } finally {
1590
+ await stopFixtureGuardian(fixture.statePath)
1591
+ await fs.rm(fixture.root, {force: true, recursive: true})
1592
+ }
1593
+ })
1594
+
1078
1595
  test("ensure-daemon atomically replaces an incompatible owner without losing retained generations", async () => {
1079
1596
  const fixture = await createFixture()
1080
1597
  const oldControlPath = fixture.socketPath
@@ -1137,6 +1654,16 @@ test("ensure-daemon atomically replaces an incompatible owner without losing ret
1137
1654
  assert.equal(after.services[0]?.process.pid, before.services[0]?.process.pid)
1138
1655
  assert.equal(after.singletons[0]?.process.pid, before.singletons[0]?.process.pid)
1139
1656
  assert.equal(retainedConnectionClosed, false, "listener-owned WebSocket must remain supervised across replacement")
1657
+ assert.ok(after.daemonPid)
1658
+ await fs.writeFile(daemonLogPath, "")
1659
+ process.kill(after.daemonPid, "SIGKILL")
1660
+ const restartedState = await waitForState(fixture.statePath, (state) => state.daemonPid !== after.daemonPid, AbortSignal.timeout(5000))
1661
+ const restarted = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: newControlPath}))
1662
+
1663
+ assert.equal(restarted.daemonPid, restartedState.daemonPid)
1664
+ assert.equal(Number((await fs.readFile(daemonPidPath, "utf8")).trim()), restarted.daemonPid)
1665
+ assert.deepEqual(restarted.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)), before.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)))
1666
+ assert.match(await fs.readFile(daemonLogPath, "utf8"), /owner state recovered/)
1140
1667
 
1141
1668
  const v3Path = await prepareRelease(fixture.root, "v3")
1142
1669
  await sendControlCommand({command: {command: "deploy", releaseId: "v3", releasePath: v3Path, revision: "v3"}, path: newControlPath})
@@ -1380,10 +1907,11 @@ async function stopFixtureGuardian(statePath) {
1380
1907
  /**
1381
1908
  * @param {string} statePath - State path.
1382
1909
  * @param {(state: RecoveryState) => boolean} predicate - Completion predicate.
1910
+ * @param {AbortSignal} [signal] - Optional deadline signal.
1383
1911
  * @returns {Promise<RecoveryState>} Matching state.
1384
1912
  */
1385
- async function waitForState(statePath, predicate) {
1386
- const watcher = fs.watch(path.dirname(statePath))
1913
+ async function waitForState(statePath, predicate, signal) {
1914
+ const watcher = fs.watch(path.dirname(statePath), {signal})
1387
1915
 
1388
1916
  try {
1389
1917
  const initial = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(statePath, "utf8")))
@@ -1437,17 +1965,6 @@ async function waitForFile(filePath, timeoutMs) {
1437
1965
  }
1438
1966
  }
1439
1967
 
1440
- /**
1441
- * @param {number} pid - Exact fixture process.
1442
- * @param {number} timeoutMs - Bounded exit wait.
1443
- */
1444
- async function waitForProcessExit(pid, timeoutMs) {
1445
- const deadline = Date.now() + timeoutMs
1446
-
1447
- while (isAlive(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10))
1448
- assert.equal(isAlive(pid), false, `process ${pid} did not exit within ${timeoutMs}ms`)
1449
- }
1450
-
1451
1968
  /**
1452
1969
  * Opens a live WebSocket through the fixture proxy.
1453
1970
  * @param {number} port - Proxy port.
@@ -1485,29 +2002,18 @@ function releaseProcessPid(status, releaseId, processId) {
1485
2002
  return pid
1486
2003
  }
1487
2004
 
1488
- /**
1489
- * @param {number} pid - Exact fixture pid.
1490
- * @returns {boolean} Whether the exact fixture process is alive.
1491
- */
1492
- function isAlive(pid) {
1493
- try {
1494
- process.kill(pid, 0)
1495
- return true
1496
- } catch (error) {
1497
- if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false
1498
- throw error
1499
- }
1500
- }
1501
-
1502
2005
  /**
1503
2006
  * @param {string} configPath - Config path.
1504
2007
  * @param {{releaseId: string, releasePath: string, revision: string}} [bootstrap] - Optional bootstrap tuple.
2008
+ * @param {{daemonPidPath?: string, startupTimeoutMs?: number}} [options] - Recovery command options.
1505
2009
  * @returns {import("node:child_process").ChildProcess} Daemon process.
1506
2010
  */
1507
- function spawnDaemon(configPath, bootstrap) {
2011
+ function spawnDaemon(configPath, bootstrap, options = {}) {
1508
2012
  const args = [binPath, "daemon", "--config", configPath]
1509
2013
 
1510
2014
  if (bootstrap) args.push("--release-id", bootstrap.releaseId, "--release-path", bootstrap.releasePath, "--revision", bootstrap.revision)
2015
+ if (options.daemonPidPath) args.push("--guardian-daemon-pid-path", options.daemonPidPath)
2016
+ if (options.startupTimeoutMs) args.push("--guardian-daemon-start-timeout-ms", String(options.startupTimeoutMs))
1511
2017
  return spawn(process.execPath, args, {stdio: ["ignore", "pipe", "pipe"]})
1512
2018
  }
1513
2019
 
@@ -1554,14 +2060,16 @@ async function runCli(args) {
1554
2060
  /**
1555
2061
  * @param {import("node:child_process").ChildProcess} child - Daemon child.
1556
2062
  * @param {string} message - Structured log message.
2063
+ * @param {{allowChildExit?: boolean}} [options] - Whether inherited descriptors may outlive the original child.
1557
2064
  */
1558
- async function waitForLog(child, message) {
2065
+ async function waitForLog(child, message, {allowChildExit = false} = {}) {
1559
2066
  assert.ok(child.stdout)
1560
2067
  child.stdout.setEncoding("utf8")
1561
2068
 
1562
2069
  await new Promise((resolve, reject) => {
1563
2070
  let buffer = ""
1564
2071
  let stderr = ""
2072
+ const timer = setTimeout(() => finish(new Error(`Timed out waiting for daemon log ${message}: ${stderr.trim()}`)), 5000)
1565
2073
  const onErrorData = (/** @type {string} */ chunk) => { stderr += chunk }
1566
2074
  const onExit = () => finish(new Error(`Daemon exited before logging ${message}: ${stderr.trim()}`))
1567
2075
  /** @param {string} chunk - Output chunk. */
@@ -1579,6 +2087,7 @@ async function waitForLog(child, message) {
1579
2087
  }
1580
2088
  /** @param {Error} [error] - Failure. */
1581
2089
  const finish = (error) => {
2090
+ clearTimeout(timer)
1582
2091
  child.off("exit", onExit)
1583
2092
  child.stdout?.off("data", onData)
1584
2093
  child.stderr?.off("data", onErrorData)
@@ -1586,7 +2095,7 @@ async function waitForLog(child, message) {
1586
2095
  else resolve(undefined)
1587
2096
  }
1588
2097
 
1589
- child.once("exit", onExit)
2098
+ if (!allowChildExit) child.once("exit", onExit)
1590
2099
  child.stdout?.on("data", onData)
1591
2100
  child.stderr?.setEncoding("utf8").on("data", onErrorData)
1592
2101
  })