rollbridge 0.1.39 → 0.1.41

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,7 +494,261 @@ test("replacement owner reconstructs one active and two draining generations aft
425
494
  }
426
495
  })
427
496
 
428
- test("owner recovery preserves a failed generation transition without firing hooks until exact resume", async () => {
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
+
751
+ test("owner recovery preserves a completed activation compensation without replaying hooks", async () => {
429
752
  const fixture = await createFixture({activationFailureRelease: "v2"})
430
753
  let owner = spawnDaemon(fixture.configPath)
431
754
 
@@ -439,7 +762,7 @@ test("owner recovery preserves a failed generation transition without firing hoo
439
762
  sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
440
763
  /activate command exited non-zero/
441
764
  )
442
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
765
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
443
766
 
444
767
  owner.kill("SIGKILL")
445
768
  await once(owner, "exit")
@@ -448,13 +771,9 @@ test("owner recovery preserves a failed generation transition without firing hoo
448
771
 
449
772
  const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
450
773
 
451
- assert.equal(recovered.generationTransition?.phase, "activating_candidate")
452
- assert.match(String(recovered.generationTransition?.error), /activate command exited non-zero/)
453
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"], "owner recovery alone must not retry a failed hook")
454
-
455
- await fs.writeFile(fixture.activationGatePath, "allow\n")
456
- await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
457
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
774
+ assert.equal(recovered.activeReleaseId, "v1")
775
+ assert.equal(recovered.generationTransition, undefined)
776
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"], "owner recovery must not replay completed compensation hooks")
458
777
 
459
778
  const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
460
779
  await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
@@ -466,36 +785,38 @@ test("owner recovery preserves a failed generation transition without firing hoo
466
785
  }
467
786
  })
468
787
 
469
- test("owner recovery replays one journaled ambiguous activation by exact generation identity", async () => {
470
- const fixture = await createFixture({activationFailureRelease: "v2"})
788
+ test("owner recovery replays one journaled ambiguous first-generation activation by exact identity", async () => {
789
+ const fixture = await createFixture({activationFailureRelease: "v1"})
471
790
  let owner = spawnDaemon(fixture.configPath)
472
791
 
473
792
  try {
474
793
  await waitForLog(owner, "control socket listening")
475
794
  const v1Path = await prepareRelease(fixture.root, "v1")
476
- const v2Path = await prepareRelease(fixture.root, "v2")
477
-
478
- await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
479
- await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}))
795
+ await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath}))
480
796
  owner.kill("SIGKILL")
481
797
  await once(owner, "exit")
482
798
 
483
799
  const ambiguous = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
484
800
 
485
801
  delete ambiguous.generationTransition.error
802
+ ambiguous.generationTransition.journalRevision += 1
486
803
  await fs.writeFile(fixture.statePath, `${JSON.stringify(ambiguous, null, 2)}\n`)
487
804
  await fs.writeFile(fixture.activationGatePath, "allow\n")
488
805
  owner = spawnDaemon(fixture.configPath)
806
+ const recoverySettled = waitForLog(owner, "release generation transition recovery settled")
807
+
489
808
  await waitForLog(owner, "control socket listening")
809
+ await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed", AbortSignal.timeout(5000))
810
+ await recoverySettled
490
811
 
491
812
  const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
492
813
 
493
- assert.equal(recovered.activeReleaseId, "v2")
814
+ assert.equal(recovered.activeReleaseId, "v1")
494
815
  assert.equal(recovered.generationTransition?.phase, "committed")
495
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
816
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
496
817
 
497
818
  const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
498
- await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
819
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
499
820
  await shutdown
500
821
  } finally {
501
822
  await killChild(owner)
@@ -504,8 +825,14 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
504
825
  }
505
826
  })
506
827
 
507
- test("owner recovery preserves exact release definitions across a candidate config authority change", async () => {
828
+ test("owner recovery preserves complete private transition authority across a candidate config change", async () => {
508
829
  const fixture = await createFixture()
830
+ const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
831
+ const initialWorker = initialProcesses.find((processConfig) => processConfig.id === "worker")
832
+
833
+ assert.ok(initialWorker)
834
+ initialWorker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
835
+ await writeConfig(fixture.configPath, fixture.config)
509
836
  const initialConfig = normalizeConfig(fixture.config, fixture.configPath)
510
837
  const owner = new RollbridgeDaemon({config: initialConfig, configPath: fixture.configPath, logger: () => {}})
511
838
  /** @type {RollbridgeDaemon | undefined} */
@@ -534,6 +861,12 @@ test("owner recovery preserves exact release definitions across a candidate conf
534
861
 
535
862
  await owner.retireCommittedOwner(undefined)
536
863
  owner.guardian?.disconnect()
864
+ const partialPublicState = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
865
+
866
+ partialPublicState.generationTransition.phase = "committed"
867
+ partialPublicState.releases = partialPublicState.releases.filter((/** @type {{releaseId: string}} */ release) => release.releaseId !== "v2")
868
+ partialPublicState.releaseReferences = partialPublicState.releaseReferences.filter((/** @type {{releaseId: string}} */ release) => release.releaseId !== "v2")
869
+ await fs.writeFile(fixture.statePath, `${JSON.stringify(partialPublicState, null, 2)}\n`)
537
870
 
538
871
  recovered = new RollbridgeDaemon({config: normalizeConfig(changedConfig, fixture.configPath), configPath: fixture.configPath, logger: () => {}})
539
872
  await recovered.start()
@@ -542,12 +875,7 @@ test("owner recovery preserves exact release definitions across a candidate conf
542
875
  assert.equal(recovered.status().generationTransition?.phase, "committed")
543
876
  assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
544
877
  } 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
- }
878
+ if (recovered) await recovered.shutdown().catch(() => {})
551
879
  await stopFixtureGuardian(fixture.statePath)
552
880
  await fs.rm(fixture.root, {force: true, recursive: true})
553
881
  }
@@ -583,6 +911,7 @@ test("owner recovery reconstructs a stopped release that still owns a committed-
583
911
 
584
912
  assert.equal(pending.activeReleaseId, "v2")
585
913
  assert.equal(pending.generationTransition?.phase, "committed_pending")
914
+ assert.equal(pending.serviceReleaseIds?.beacon, "v2")
586
915
  assert.equal(pending.singletonReleaseIds?.singleton, "v1")
587
916
  const stoppedRelease = pending.releases.find((release) => release.releaseId === "v1" && release.state === "stopped")
588
917
 
@@ -622,6 +951,9 @@ test("owner recovery reconstructs a stopped release that still owns a committed-
622
951
 
623
952
  test("owner recovery uses the owning release singleton definition during a committed-pending config change", async () => {
624
953
  const fixture = await createFixture()
954
+ const fixtureProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
955
+
956
+ fixture.config.processes = fixtureProcesses.filter((processConfig) => processConfig.id !== "beacon")
625
957
  const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
626
958
  const initialSingleton = initialProcesses.find((processConfig) => processConfig.id === "singleton")
627
959
 
@@ -688,6 +1020,67 @@ test("owner recovery uses the owning release singleton definition during a commi
688
1020
  }
689
1021
  })
690
1022
 
1023
+ test("owner recovery retains a stopped previous release until committed-pending singleton work completes", async () => {
1024
+ const fixture = await createFixture()
1025
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
1026
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
1027
+
1028
+ assert.ok(worker)
1029
+ worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
1030
+ await writeConfig(fixture.configPath, fixture.config)
1031
+ const config = normalizeConfig(fixture.config, fixture.configPath)
1032
+ const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
1033
+ const replaceSingletons = owner.replaceSingletons.bind(owner)
1034
+ /** @type {() => void} */
1035
+ let continueReplacement = () => {}
1036
+ /** @type {() => void} */
1037
+ let markReplacementComplete = () => {}
1038
+ const replacementGate = new Promise((resolve) => { continueReplacement = () => resolve(undefined) })
1039
+ const replacementComplete = new Promise((resolve) => { markReplacementComplete = () => resolve(undefined) })
1040
+ const v1Path = await prepareRelease(fixture.root, "v1")
1041
+ const v2Path = await prepareRelease(fixture.root, "v2")
1042
+ /** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
1043
+ let deployPromise
1044
+ /** @type {RollbridgeDaemon | undefined} */
1045
+ let recovered
1046
+
1047
+ owner.replaceSingletons = async (release) => {
1048
+ await replaceSingletons(release)
1049
+ if (release.releaseId === "v2") {
1050
+ markReplacementComplete()
1051
+ await replacementGate
1052
+ }
1053
+ }
1054
+
1055
+ try {
1056
+ await owner.start()
1057
+ await owner.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
1058
+ deployPromise = owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
1059
+ void deployPromise.catch(() => {})
1060
+ await replacementComplete
1061
+ 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))
1062
+
1063
+ assert.deepEqual(pending.releaseReferences.map((reference) => reference.releaseId), ["v1", "v2"])
1064
+ assert.equal(pending.singletonReleaseIds?.singleton, "v2")
1065
+ await owner.retireCommittedOwner(undefined)
1066
+ owner.guardian?.disconnect()
1067
+
1068
+ recovered = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
1069
+ await recovered.start()
1070
+
1071
+ assert.equal(recovered.status().generationTransition?.phase, "committed")
1072
+ assert.equal(recovered.status().activeReleaseId, "v2")
1073
+ } finally {
1074
+ continueReplacement()
1075
+ await deployPromise?.catch(() => {})
1076
+ if (recovered) await recovered.shutdown().catch(() => {})
1077
+ else await owner.shutdown().catch(() => {})
1078
+ owner.guardian?.disconnect()
1079
+ await stopFixtureGuardian(fixture.statePath)
1080
+ await fs.rm(fixture.root, {force: true, recursive: true})
1081
+ }
1082
+ })
1083
+
691
1084
  test("owner recovery replays ambiguous retirement with the previous release's exact definition", async () => {
692
1085
  const fixture = await createFixture()
693
1086
  const retirementGatePath = path.join(fixture.root, "retirement.allow")
@@ -722,8 +1115,11 @@ test("owner recovery replays ambiguous retirement with the previous release's ex
722
1115
  await fs.writeFile(retirementGatePath, "release retirement\n")
723
1116
 
724
1117
  owner = spawnDaemon(fixture.configPath)
1118
+ const recoverySettled = waitForLog(owner, "release generation transition recovery settled")
725
1119
 
726
1120
  await waitForLog(owner, "control socket listening")
1121
+ await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed", AbortSignal.timeout(5000))
1122
+ await recoverySettled
727
1123
  assert.equal((await fs.readFile(retirementWaitingPath, "utf8")).trim().split("\n").length, 2, "ambiguous retirement must replay exactly once")
728
1124
  assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v1", "activate:v2"])
729
1125
  assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2")
@@ -760,7 +1156,7 @@ test("owner recovery rejects config identity mismatch without changing the valid
760
1156
  const rejected = await runDaemon(fixture.configPath)
761
1157
 
762
1158
  assert.notEqual(rejected.code, 0)
763
- assert.match(rejected.output, /config identity does not match/)
1159
+ assert.match(rejected.output, /authority does not match/)
764
1160
  assert.equal(await fs.readFile(fixture.statePath, "utf8"), validState)
765
1161
 
766
1162
  await writeConfig(fixture.configPath, fixture.config)
@@ -819,9 +1215,10 @@ test("failed recovery bootstrap keeps the reconstructed active generation servin
819
1215
  }
820
1216
  })
821
1217
 
822
- test("owner recovery fails closed on a partial snapshot and preserves it for repair", async () => {
1218
+ test("owner recovery repairs a partial public snapshot from committed guardian state", async () => {
823
1219
  const fixture = await createFixture()
824
- let owner = spawnDaemon(fixture.configPath)
1220
+ const owner = spawnDaemon(fixture.configPath)
1221
+ let recoveredDaemonPid
825
1222
  let workerPid
826
1223
 
827
1224
  try {
@@ -838,21 +1235,24 @@ test("owner recovery fails closed on a partial snapshot and preserves it for rep
838
1235
  const partialState = {...validState, releases: []}
839
1236
  await fs.writeFile(fixture.statePath, `${JSON.stringify(partialState, null, 2)}\n`)
840
1237
 
841
- const rejected = await runDaemon(fixture.configPath)
1238
+ const repairedState = await waitForState(
1239
+ fixture.statePath,
1240
+ (state) => state.daemonPid !== validState.daemonPid && state.releases.some((release) => release.releaseId === "v1"),
1241
+ AbortSignal.timeout(5000)
1242
+ )
1243
+ recoveredDaemonPid = repairedState.daemonPid
1244
+ const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
842
1245
 
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)
1246
+ assert.equal(recovered.activeReleaseId, "v1")
1247
+ assert.equal(releaseProcessPid(recovered, "v1", "worker"), workerPid)
1248
+ assert.notDeepEqual(JSON.parse(await fs.readFile(fixture.statePath, "utf8")), partialState)
846
1249
 
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
1250
  const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
851
1251
  await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
852
1252
  await shutdown
853
- await once(owner, "exit")
854
1253
  } finally {
855
1254
  await killChild(owner)
1255
+ if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
856
1256
  if (workerPid) {
857
1257
  try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact group already exited. */ }
858
1258
  }
@@ -1014,6 +1414,75 @@ test("deploy rejects a live ownerRecovery mode change", async () => {
1014
1414
  }
1015
1415
  })
1016
1416
 
1417
+ test("deploy rejects a live activation lifecycle mode change", async () => {
1418
+ const fixture = await createFixture()
1419
+ const owner = spawnDaemon(fixture.configPath)
1420
+ const v1Path = await prepareRelease(fixture.root, "v1")
1421
+ const v2Path = await prepareRelease(fixture.root, "v2")
1422
+
1423
+ try {
1424
+ await waitForLog(owner, "control socket listening")
1425
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
1426
+ const changedConfig = structuredClone(fixture.config)
1427
+ const jobs = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (changedConfig.processes).find((processConfig) => processConfig.id === "jobs")
1428
+ const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs?.lifecycle)
1429
+
1430
+ delete lifecycle.activateCommand
1431
+ await writeConfig(fixture.configPath, changedConfig)
1432
+ await assert.rejects(
1433
+ sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
1434
+ /lifecycle\.activateCommand.*cannot be applied live/
1435
+ )
1436
+
1437
+ await writeConfig(fixture.configPath, fixture.config)
1438
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
1439
+
1440
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
1441
+ await shutdown
1442
+ } finally {
1443
+ await killChild(owner)
1444
+ await stopFixtureGuardian(fixture.statePath)
1445
+ await fs.rm(fixture.root, {force: true, recursive: true})
1446
+ }
1447
+ })
1448
+
1449
+ test("public state does not advance when private guardian publication fails", async () => {
1450
+ const fixture = await createFixture()
1451
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
1452
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
1453
+
1454
+ assert.ok(worker)
1455
+ worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
1456
+ await writeConfig(fixture.configPath, fixture.config)
1457
+ const config = normalizeConfig(fixture.config, fixture.configPath)
1458
+ const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
1459
+ const releasePath = await prepareRelease(fixture.root, "v1")
1460
+
1461
+ try {
1462
+ await owner.start()
1463
+ await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
1464
+ if (owner.pendingWrite) await owner.pendingWrite
1465
+ const before = await fs.readFile(fixture.statePath, "utf8")
1466
+ const publishOwnerState = owner.publishOwnerState.bind(owner)
1467
+
1468
+ owner.ownerReady = true
1469
+ owner.publishOwnerState = async () => { throw new Error("injected guardian publication failure") }
1470
+ const write = owner.persistState({throwOnError: true})
1471
+
1472
+ assert.ok(write)
1473
+ await assert.rejects(write, /injected guardian publication failure/)
1474
+ assert.equal(await fs.readFile(fixture.statePath, "utf8"), before)
1475
+ owner.publishOwnerState = publishOwnerState
1476
+ await owner.persistState({throwOnError: true})
1477
+ await owner.shutdown()
1478
+ } finally {
1479
+ owner.publishOwnerState = RollbridgeDaemon.prototype.publishOwnerState.bind(owner)
1480
+ await owner.shutdown().catch(() => {})
1481
+ await stopFixtureGuardian(fixture.statePath)
1482
+ await fs.rm(fixture.root, {force: true, recursive: true})
1483
+ }
1484
+ })
1485
+
1017
1486
  test("replacement removes only guardian-owned candidate inventory left before deploy commit", async () => {
1018
1487
  const fixture = await createFixture()
1019
1488
  let owner = spawnDaemon(fixture.configPath)
@@ -1056,7 +1525,7 @@ test("replacement removes only guardian-owned candidate inventory left before de
1056
1525
 
1057
1526
  assert.equal(recovered.activeReleaseId, "v1")
1058
1527
  assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
1059
- assert.equal(isAlive(candidatePid), false, "uncommitted candidate must be stopped before replacement becomes healthy")
1528
+ assert.equal(isProcessRunning(candidatePid), false, "uncommitted candidate must be stopped before replacement becomes healthy")
1060
1529
 
1061
1530
  await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
1062
1531
  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 +1536,7 @@ test("replacement removes only guardian-owned candidate inventory left before de
1067
1536
  await once(owner, "exit")
1068
1537
  } finally {
1069
1538
  await killChild(owner)
1070
- if (candidatePid && isAlive(candidatePid)) {
1539
+ if (candidatePid && isProcessRunning(candidatePid)) {
1071
1540
  try { process.kill(-candidatePid, "SIGKILL") } catch (_error) { /* Exact candidate group already exited. */ }
1072
1541
  }
1073
1542
  await stopFixtureGuardian(fixture.statePath)
@@ -1075,6 +1544,47 @@ test("replacement removes only guardian-owned candidate inventory left before de
1075
1544
  }
1076
1545
  })
1077
1546
 
1547
+ test("ensure-daemon replaces a same-authority owner whose control socket disappeared", async () => {
1548
+ const fixture = await createFixture()
1549
+ const runtimePath = path.join(fixture.root, "runtime")
1550
+ const daemonLogPath = path.join(fixture.root, "same-authority-replacement.log")
1551
+ const daemonPidPath = path.join(fixture.root, "same-authority-replacement.pid")
1552
+ const v1Path = await prepareRelease(fixture.root, "v1")
1553
+ const ensureArgs = [
1554
+ "ensure-daemon", "--config", fixture.configPath,
1555
+ "--daemon-log-path", daemonLogPath,
1556
+ "--daemon-pid-path", daemonPidPath,
1557
+ "--daemon-runtime-path", runtimePath,
1558
+ "--daemon-start-timeout-ms", "5000"
1559
+ ]
1560
+
1561
+ try {
1562
+ const first = await runCli(ensureArgs)
1563
+
1564
+ assert.equal(first.code, 0, first.output)
1565
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
1566
+ const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
1567
+
1568
+ await fs.rm(fixture.socketPath, {force: true})
1569
+ const replacement = await runCli(ensureArgs)
1570
+
1571
+ assert.equal(replacement.code, 0, `${replacement.output}\n${await fs.readFile(daemonLogPath, "utf8")}`)
1572
+ const after = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
1573
+
1574
+ assert.equal(after.activeReleaseId, "v1")
1575
+ assert.notEqual(after.daemonPid, before.daemonPid)
1576
+ await waitForProcessExit(before.daemonPid)
1577
+
1578
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
1579
+
1580
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
1581
+ await shutdown
1582
+ } finally {
1583
+ await stopFixtureGuardian(fixture.statePath)
1584
+ await fs.rm(fixture.root, {force: true, recursive: true})
1585
+ }
1586
+ })
1587
+
1078
1588
  test("ensure-daemon atomically replaces an incompatible owner without losing retained generations", async () => {
1079
1589
  const fixture = await createFixture()
1080
1590
  const oldControlPath = fixture.socketPath
@@ -1137,6 +1647,16 @@ test("ensure-daemon atomically replaces an incompatible owner without losing ret
1137
1647
  assert.equal(after.services[0]?.process.pid, before.services[0]?.process.pid)
1138
1648
  assert.equal(after.singletons[0]?.process.pid, before.singletons[0]?.process.pid)
1139
1649
  assert.equal(retainedConnectionClosed, false, "listener-owned WebSocket must remain supervised across replacement")
1650
+ assert.ok(after.daemonPid)
1651
+ await fs.writeFile(daemonLogPath, "")
1652
+ process.kill(after.daemonPid, "SIGKILL")
1653
+ const restartedState = await waitForState(fixture.statePath, (state) => state.daemonPid !== after.daemonPid, AbortSignal.timeout(5000))
1654
+ const restarted = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: newControlPath}))
1655
+
1656
+ assert.equal(restarted.daemonPid, restartedState.daemonPid)
1657
+ assert.equal(Number((await fs.readFile(daemonPidPath, "utf8")).trim()), restarted.daemonPid)
1658
+ assert.deepEqual(restarted.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)), before.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)))
1659
+ assert.match(await fs.readFile(daemonLogPath, "utf8"), /owner state recovered/)
1140
1660
 
1141
1661
  const v3Path = await prepareRelease(fixture.root, "v3")
1142
1662
  await sendControlCommand({command: {command: "deploy", releaseId: "v3", releasePath: v3Path, revision: "v3"}, path: newControlPath})
@@ -1380,10 +1900,11 @@ async function stopFixtureGuardian(statePath) {
1380
1900
  /**
1381
1901
  * @param {string} statePath - State path.
1382
1902
  * @param {(state: RecoveryState) => boolean} predicate - Completion predicate.
1903
+ * @param {AbortSignal} [signal] - Optional deadline signal.
1383
1904
  * @returns {Promise<RecoveryState>} Matching state.
1384
1905
  */
1385
- async function waitForState(statePath, predicate) {
1386
- const watcher = fs.watch(path.dirname(statePath))
1906
+ async function waitForState(statePath, predicate, signal) {
1907
+ const watcher = fs.watch(path.dirname(statePath), {signal})
1387
1908
 
1388
1909
  try {
1389
1910
  const initial = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(statePath, "utf8")))
@@ -1437,17 +1958,6 @@ async function waitForFile(filePath, timeoutMs) {
1437
1958
  }
1438
1959
  }
1439
1960
 
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
1961
  /**
1452
1962
  * Opens a live WebSocket through the fixture proxy.
1453
1963
  * @param {number} port - Proxy port.
@@ -1485,29 +1995,18 @@ function releaseProcessPid(status, releaseId, processId) {
1485
1995
  return pid
1486
1996
  }
1487
1997
 
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
1998
  /**
1503
1999
  * @param {string} configPath - Config path.
1504
2000
  * @param {{releaseId: string, releasePath: string, revision: string}} [bootstrap] - Optional bootstrap tuple.
2001
+ * @param {{daemonPidPath?: string, startupTimeoutMs?: number}} [options] - Recovery command options.
1505
2002
  * @returns {import("node:child_process").ChildProcess} Daemon process.
1506
2003
  */
1507
- function spawnDaemon(configPath, bootstrap) {
2004
+ function spawnDaemon(configPath, bootstrap, options = {}) {
1508
2005
  const args = [binPath, "daemon", "--config", configPath]
1509
2006
 
1510
2007
  if (bootstrap) args.push("--release-id", bootstrap.releaseId, "--release-path", bootstrap.releasePath, "--revision", bootstrap.revision)
2008
+ if (options.daemonPidPath) args.push("--guardian-daemon-pid-path", options.daemonPidPath)
2009
+ if (options.startupTimeoutMs) args.push("--guardian-daemon-start-timeout-ms", String(options.startupTimeoutMs))
1511
2010
  return spawn(process.execPath, args, {stdio: ["ignore", "pipe", "pipe"]})
1512
2011
  }
1513
2012
 
@@ -1554,14 +2053,16 @@ async function runCli(args) {
1554
2053
  /**
1555
2054
  * @param {import("node:child_process").ChildProcess} child - Daemon child.
1556
2055
  * @param {string} message - Structured log message.
2056
+ * @param {{allowChildExit?: boolean}} [options] - Whether inherited descriptors may outlive the original child.
1557
2057
  */
1558
- async function waitForLog(child, message) {
2058
+ async function waitForLog(child, message, {allowChildExit = false} = {}) {
1559
2059
  assert.ok(child.stdout)
1560
2060
  child.stdout.setEncoding("utf8")
1561
2061
 
1562
2062
  await new Promise((resolve, reject) => {
1563
2063
  let buffer = ""
1564
2064
  let stderr = ""
2065
+ const timer = setTimeout(() => finish(new Error(`Timed out waiting for daemon log ${message}: ${stderr.trim()}`)), 5000)
1565
2066
  const onErrorData = (/** @type {string} */ chunk) => { stderr += chunk }
1566
2067
  const onExit = () => finish(new Error(`Daemon exited before logging ${message}: ${stderr.trim()}`))
1567
2068
  /** @param {string} chunk - Output chunk. */
@@ -1579,6 +2080,7 @@ async function waitForLog(child, message) {
1579
2080
  }
1580
2081
  /** @param {Error} [error] - Failure. */
1581
2082
  const finish = (error) => {
2083
+ clearTimeout(timer)
1582
2084
  child.off("exit", onExit)
1583
2085
  child.stdout?.off("data", onData)
1584
2086
  child.stderr?.off("data", onErrorData)
@@ -1586,7 +2088,7 @@ async function waitForLog(child, message) {
1586
2088
  else resolve(undefined)
1587
2089
  }
1588
2090
 
1589
- child.once("exit", onExit)
2091
+ if (!allowChildExit) child.once("exit", onExit)
1590
2092
  child.stdout?.on("data", onData)
1591
2093
  child.stderr?.setEncoding("utf8").on("data", onErrorData)
1592
2094
  })