rollbridge 0.1.30 → 0.1.32

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.
@@ -9,8 +9,9 @@ import os from "node:os"
9
9
  import path from "node:path"
10
10
  import test from "node:test"
11
11
  import {fileURLToPath} from "node:url"
12
+ import {normalizeConfig} from "../src/config.js"
12
13
  import {sendControlCommand} from "../src/control-client.js"
13
- import {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
14
+ import RollbridgeDaemon, {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
14
15
  import GuardianClient from "../src/guardian-client.js"
15
16
  import {findAvailablePort} from "../src/port-allocator.js"
16
17
 
@@ -248,6 +249,50 @@ test("ensure-daemon atomically replaces incompatible config, socket, and package
248
249
  }
249
250
  })
250
251
 
252
+ test("same-authority replacement commits after a retired incumbent already removed its control socket", async () => {
253
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-retired-control-"))
254
+ const socketPath = path.join(root, "rollbridge.sock")
255
+ const statePath = path.join(root, "state.json")
256
+ const releasePath = path.join(root, "v1")
257
+ const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath}))
258
+ const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
259
+ let replacement
260
+
261
+ try {
262
+ await fs.mkdir(releasePath)
263
+ await makeFifo(path.join(releasePath, "worker.fifo"))
264
+ await owner.start()
265
+ await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
266
+ await Promise.all([...owner.releases.values()].map((release) => release.quiesce()))
267
+ await owner.closeServer(owner.controlServer)
268
+ await owner.removeControlSocket()
269
+ await owner.closeServer(owner.proxyServer)
270
+ const retired = owner.status()
271
+ const processState = retired.releases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
272
+
273
+ assert.deepEqual(processState?.map(({state}) => state), ["quiesced", "quiesced"])
274
+ assert.equal((await owner.guardian?.replacementStatus())?.ownerClaimed, true)
275
+ await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
276
+
277
+ replacement = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
278
+ await replacement.replaceIncompatibleOwner()
279
+ const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
280
+ const recoveredReleases = /** @type {{processes: {id: string, pid?: number, state: string}[]}[]} */ (recovered.releases)
281
+
282
+ assert.equal(recovered.activeReleaseId, "v1")
283
+ assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
284
+ assert.deepEqual(recoveredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state})), processState)
285
+ } finally {
286
+ const shutdown = replacement?.controlCommandsReady ? replacement.shutdown().catch(() => {}) : owner.shutdown().catch(() => {})
287
+
288
+ await Promise.all([fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}), shutdown])
289
+ owner.guardian?.disconnect()
290
+ replacement?.guardian?.disconnect()
291
+ await stopGuardian(statePath)
292
+ await fs.rm(root, {force: true, recursive: true})
293
+ }
294
+ })
295
+
251
296
  test("replacement refuses to overwrite an unrelated live final control socket and preserves the owner", async () => {
252
297
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-fence-"))
253
298
  const oldSocketPath = path.join(root, "old.sock")
@@ -377,6 +422,100 @@ test("replacement transfers an unchanged fixed proxy listener without reusePort"
377
422
  }
378
423
  })
379
424
 
425
+ test("owner replacement preserves committed generation metadata without firing lifecycle hooks", async () => {
426
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-generation-"))
427
+ const oldSocketPath = path.join(root, "old.sock")
428
+ const newSocketPath = path.join(root, "new.sock")
429
+ const statePath = path.join(root, "state.json")
430
+ const configPath = path.join(root, "rollbridge.cjs")
431
+ const releasePath = path.join(root, "v1")
432
+ const lifecycleLogPath = path.join(root, "generation.lifecycle")
433
+ let owner
434
+ let candidate
435
+
436
+ try {
437
+ await fs.mkdir(releasePath)
438
+ await makeFifo(path.join(releasePath, "worker.fifo"))
439
+ await writeConfig(configPath, config({activationLogPath: lifecycleLogPath, controlPath: oldSocketPath, extraCompanion: false, statePath}))
440
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
441
+ await waitForLog(owner, "control socket listening")
442
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: oldSocketPath})
443
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\n")
444
+
445
+ await writeConfig(configPath, config({activationLogPath: lifecycleLogPath, controlPath: newSocketPath, extraCompanion: true, statePath}))
446
+ candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
447
+ await waitForLog(candidate, "owner replacement committed")
448
+
449
+ const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
450
+ const generationTransition = status.generationTransition
451
+
452
+ assert.equal(status.activeReleaseId, "v1")
453
+ assert.ok(generationTransition && typeof generationTransition === "object" && !Array.isArray(generationTransition))
454
+ assert.equal(generationTransition.phase, "committed")
455
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\n", "owner replacement must not reactivate an already committed generation")
456
+
457
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
458
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
459
+ await shutdown
460
+ } finally {
461
+ for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
462
+ await stopGuardian(statePath)
463
+ await fs.rm(root, {force: true, recursive: true})
464
+ }
465
+ })
466
+
467
+ test("owner replacement preserves a failed generation transition without retrying its hook", async () => {
468
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-failed-generation-"))
469
+ const oldSocketPath = path.join(root, "old.sock")
470
+ const newSocketPath = path.join(root, "new.sock")
471
+ const statePath = path.join(root, "state.json")
472
+ const configPath = path.join(root, "rollbridge.cjs")
473
+ const lifecycleLogPath = path.join(root, "generation.lifecycle")
474
+ const v1Path = path.join(root, "v1")
475
+ const v2Path = path.join(root, "v2")
476
+ let owner
477
+ let candidate
478
+ const failedConfig = (/** @type {string} */ controlPath, /** @type {boolean} */ extraCompanion) => {
479
+ const raw = config({activationLogPath: lifecycleLogPath, controlPath, extraCompanion, statePath})
480
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (raw.processes)
481
+ const generationMain = processes.find((processConfig) => processConfig.id === "generation-main")
482
+ const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (generationMain?.lifecycle)
483
+
484
+ lifecycle.activateCommand = `[ "$ROLLBRIDGE_RELEASE_ID" != v2 ] || exit 24; printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
485
+ return raw
486
+ }
487
+
488
+ try {
489
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
490
+ await Promise.all([makeFifo(path.join(v1Path, "worker.fifo")), makeFifo(path.join(v2Path, "worker.fifo"))])
491
+ await writeConfig(configPath, failedConfig(oldSocketPath, false))
492
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
493
+ await waitForLog(owner, "control socket listening")
494
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
495
+ await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath}), /activate command exited non-zero/)
496
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n")
497
+
498
+ await writeConfig(configPath, failedConfig(newSocketPath, true))
499
+ candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
500
+ await waitForLog(candidate, "owner replacement committed")
501
+ const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
502
+ const generationTransition = status.generationTransition
503
+
504
+ assert.ok(generationTransition && typeof generationTransition === "object" && !Array.isArray(generationTransition))
505
+ assert.equal(generationTransition.phase, "activating_candidate")
506
+ assert.match(String(generationTransition.error), /activate command exited non-zero/)
507
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n", "replacement must preserve, not retry, the failed activation")
508
+
509
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
510
+ await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
511
+ await shutdown
512
+ } finally {
513
+ for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
514
+ await stopGuardian(statePath)
515
+ await fs.rm(root, {force: true, recursive: true})
516
+ }
517
+ })
518
+
380
519
  test("replacement publishes an unchanged control path only after incumbent retirement", async () => {
381
520
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-same-control-"))
382
521
  const socketPath = path.join(root, "rollbridge.sock")
@@ -473,10 +612,10 @@ test("prepared replacement fences incumbent mutations until abort", async () =>
473
612
  })
474
613
 
475
614
  /**
476
- * @param {{controlPath: string, extraCompanion: boolean, proxyPort?: number, statePath: string}} options - Fixture options.
615
+ * @param {{activationLogPath?: string, controlPath: string, extraCompanion: boolean, proxyPort?: number, statePath: string}} options - Fixture options.
477
616
  * @returns {Record<string, import("../src/json.js").JsonValue>} Raw fixture config.
478
617
  */
479
- function config({controlPath, extraCompanion, proxyPort = 0, statePath}) {
618
+ function config({activationLogPath, controlPath, extraCompanion, proxyPort = 0, statePath}) {
480
619
  const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`
481
620
  const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ ([
482
621
  {
@@ -489,6 +628,17 @@ function config({controlPath, extraCompanion, proxyPort = 0, statePath}) {
489
628
  {command, health: {intervalMs: 25, path: "/ping", timeoutMs: 3000}, id: "web", policy: "proxied", port: {from: 0, to: 0}}
490
629
  ])
491
630
 
631
+ if (activationLogPath) processes.unshift({
632
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
633
+ deployStrategy: "handoff",
634
+ id: "generation-main",
635
+ lifecycle: {
636
+ activateCommand: `printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(activationLogPath)}`,
637
+ quietCommand: `printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(activationLogPath)}`
638
+ },
639
+ policy: "service",
640
+ port: {from: 23000, to: 23999}
641
+ })
492
642
  if (extraCompanion) processes.splice(1, 0, {command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`, id: "metrics", policy: "companion"})
493
643
  return {
494
644
  application: "owner-replacement-test",
@@ -505,6 +505,277 @@ test("candidate activation retires jobs-main with its workers without waiting fo
505
505
  }
506
506
  })
507
507
 
508
+ test("opt-in generation lifecycle retires the old generation before activating and committing the candidate", async () => {
509
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, nonBlockingDrainWorker: true, webDependsOnService: true})
510
+ const daemon = await startDaemon(fixture.config)
511
+
512
+ try {
513
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
514
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
515
+ assert.equal(daemon.status().activeReleaseId, "v1")
516
+
517
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
518
+
519
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
520
+ assert.equal(daemon.status().activeReleaseId, "v2")
521
+ assert.equal(daemon.status().generationTransition?.phase, "committed")
522
+ } finally {
523
+ await daemon.shutdown()
524
+ await fs.rm(fixture.root, {force: true, recursive: true})
525
+ }
526
+ })
527
+
528
+ test("generation commit is durable before awaited post-transition work", async () => {
529
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, includeSingleton: true, webDependsOnService: true})
530
+ const daemon = await startDaemon(fixture.config)
531
+ const replaceSingletons = daemon.replaceSingletons.bind(daemon)
532
+ /** @type {() => void} */
533
+ let markReplacementStarted = () => {}
534
+ /** @type {() => void} */
535
+ let releaseReplacement = () => {}
536
+ const replacementStarted = new Promise((resolve) => { markReplacementStarted = () => resolve(undefined) })
537
+ const replacementGate = new Promise((resolve) => { releaseReplacement = () => resolve(undefined) })
538
+ /** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
539
+ let deployPromise
540
+
541
+ try {
542
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
543
+ daemon.replaceSingletons = async (release) => {
544
+ if (release.releaseId === "v2") {
545
+ markReplacementStarted()
546
+ await replacementGate
547
+ }
548
+ await replaceSingletons(release)
549
+ }
550
+
551
+ deployPromise = daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
552
+ await replacementStarted
553
+
554
+ const persisted = /** @type {{activeReleaseId?: string, generationTransition?: {phase?: string}, singletonReleaseIds?: Record<string, string>} | undefined} */ (await readState(fixture.statePath))
555
+
556
+ assert.equal(daemon.status().activeReleaseId, "v2")
557
+ assert.equal(persisted?.activeReleaseId, "v2")
558
+ assert.equal(persisted?.generationTransition?.phase, "committed_pending")
559
+ assert.equal(persisted?.singletonReleaseIds?.["jobs-main"], "v1")
560
+
561
+ releaseReplacement()
562
+ await deployPromise
563
+ assert.equal(daemon.status().generationTransition?.phase, "committed")
564
+ } finally {
565
+ releaseReplacement()
566
+ await deployPromise?.catch(() => {})
567
+ await daemon.shutdown()
568
+ await fs.rm(fixture.root, {force: true, recursive: true})
569
+ }
570
+ })
571
+
572
+ test("exact committed retry finishes pending singleton replacement before success", async () => {
573
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, includeSingleton: true, singletonCwd: "{{releasePath}}/{{releaseId}}", webDependsOnService: true})
574
+ const daemon = await startDaemon(fixture.config)
575
+
576
+ await fs.mkdir(path.join(fixture.root, "v1"))
577
+
578
+ try {
579
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
580
+ await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /ENOENT/)
581
+
582
+ assert.equal(daemon.status().activeReleaseId, "v2", "traffic remains durably committed")
583
+ assert.equal(daemon.status().generationTransition?.phase, "committed_pending")
584
+ assert.notEqual(daemon.status().singletons[0]?.process.state, "running")
585
+
586
+ await fs.mkdir(path.join(fixture.root, "v2"))
587
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
588
+
589
+ assert.equal(daemon.status().generationTransition?.phase, "committed")
590
+ assert.equal(daemon.status().singletons[0]?.process.state, "running")
591
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
592
+ } finally {
593
+ await daemon.shutdown()
594
+ await fs.rm(fixture.root, {force: true, recursive: true})
595
+ }
596
+ })
597
+
598
+ test("first generation is not committed when its activation acknowledgement fails", async () => {
599
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: "v1", nonBlockingDrainWorker: true, webDependsOnService: true})
600
+ const daemon = await startDaemon(fixture.config)
601
+
602
+ try {
603
+ await assert.rejects(() => daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"}), /activate command exited non-zero/)
604
+ const status = daemon.status()
605
+
606
+ assert.equal(status.activeReleaseId, null)
607
+ assert.equal(status.generationTransition?.phase, "activating_candidate")
608
+ assert.deepEqual(status.releaseReferences.map((reference) => reference.releaseId), ["v1"])
609
+ } finally {
610
+ await daemon.shutdown()
611
+ await fs.rm(fixture.root, {force: true, recursive: true})
612
+ }
613
+ })
614
+
615
+ test("retirement failure retains the exact transition, blocks other deploys, and exact resume continues it", async () => {
616
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceQuietFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
617
+ const daemon = await startDaemon(fixture.config)
618
+
619
+ try {
620
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
621
+ await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /retirement quiescence failed/)
622
+
623
+ const failed = daemon.status()
624
+
625
+ assert.equal(failed.activeReleaseId, "v1")
626
+ assert.equal(failed.generationTransition?.phase, "retiring_previous")
627
+ assert.match(String(failed.generationTransition?.error), /quiet command exited non-zero/)
628
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
629
+ await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
630
+
631
+ await fs.writeFile(fixture.retirementGatePath, "allow\n")
632
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
633
+
634
+ assert.equal(daemon.status().activeReleaseId, "v2")
635
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
636
+ } finally {
637
+ await daemon.shutdown()
638
+ await fs.rm(fixture.root, {force: true, recursive: true})
639
+ }
640
+ })
641
+
642
+ test("candidate activation failure never reactivates old and exact resume activates only the candidate", async () => {
643
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
644
+ const daemon = await startDaemon(fixture.config)
645
+
646
+ try {
647
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
648
+ await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
649
+
650
+ const failed = daemon.status()
651
+
652
+ assert.equal(failed.activeReleaseId, "v1")
653
+ assert.equal(failed.generationTransition?.phase, "activating_candidate")
654
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
655
+
656
+ await fs.writeFile(fixture.activationGatePath, "allow\n")
657
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
658
+
659
+ assert.equal(daemon.status().activeReleaseId, "v2")
660
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
661
+ } finally {
662
+ await daemon.shutdown()
663
+ await fs.rm(fixture.root, {force: true, recursive: true})
664
+ }
665
+ })
666
+
667
+ test("unresolved generation transition fences stop, restart, and rollback mutations", async () => {
668
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
669
+ const daemon = await startDaemon(fixture.config)
670
+
671
+ try {
672
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
673
+ await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
674
+
675
+ await assert.rejects(() => daemon.stopRelease("v2"), /cannot stop.*generation transition.*unresolved/i)
676
+ await assert.rejects(() => daemon.restartProcesses({processId: "beacon"}), /cannot restart.*generation transition.*unresolved/i)
677
+ await assert.rejects(() => daemon.rollback({releaseId: "v2"}), /cannot rollback.*generation transition.*unresolved/i)
678
+
679
+ assert.notEqual(statusRelease(daemon, "v2").processes.find((entry) => entry.id === "web")?.state, "stopped")
680
+ await fs.writeFile(fixture.activationGatePath, "allow\n")
681
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
682
+ assert.equal(await fetchText(daemon, "/release"), "v2")
683
+ } finally {
684
+ await daemon.shutdown()
685
+ await fs.rm(fixture.root, {force: true, recursive: true})
686
+ }
687
+ })
688
+
689
+ test("active generation restores its exact lifecycle role after coordinator auto-restart", async () => {
690
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, webDependsOnService: true})
691
+ const daemon = await startDaemon(fixture.config)
692
+
693
+ try {
694
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
695
+ const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
696
+
697
+ assert.ok(coordinator?.pid)
698
+ process.kill(-coordinator.pid, "SIGKILL")
699
+ await waitFor(async () => (await lifecycleEvents(fixture.lifecycleLogPath)).length === 2 && statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")?.state === "running", 3000)
700
+
701
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "activate:v1"])
702
+ assert.equal(statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")?.state, "running")
703
+ assert.equal(await fetchText(daemon, "/release"), "v1")
704
+ } finally {
705
+ await daemon.shutdown()
706
+ await fs.rm(fixture.root, {force: true, recursive: true})
707
+ }
708
+ })
709
+
710
+ test("failed active-role restoration is loud and never reports the restarted coordinator running", async () => {
711
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: "v1", webDependsOnService: true})
712
+ const daemon = await startDaemon(fixture.config)
713
+
714
+ await fs.writeFile(fixture.activationGatePath, "allow\n")
715
+
716
+ try {
717
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
718
+ await fs.rm(fixture.activationGatePath)
719
+ const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
720
+
721
+ assert.ok(coordinator?.pid)
722
+ process.kill(-coordinator.pid, "SIGKILL")
723
+ await waitFor(() => {
724
+ const status = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
725
+
726
+ return status?.state === "failed" && status.restarts === 1
727
+ }, 3000)
728
+
729
+ const failed = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
730
+
731
+ assert.equal(failed?.lifecycleRole, "active")
732
+ assert.equal(failed?.restarts, 1, "role restoration failure must not create an internal retry loop")
733
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
734
+ } finally {
735
+ await daemon.shutdown()
736
+ await fs.rm(fixture.root, {force: true, recursive: true})
737
+ }
738
+ })
739
+
740
+ test("retired generation coordinator remains fenced after exit", async () => {
741
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, webDependsOnService: true})
742
+ const daemon = await startDaemon(fixture.config)
743
+ /** @type {WebSocket | undefined} */
744
+ let socket
745
+
746
+ try {
747
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
748
+ socket = await openWebSocket(daemon)
749
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
750
+ const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
751
+ const coordinatorProcess = daemon.releases.get("v1")?.getProcess("beacon")
752
+
753
+ assert.ok(coordinator?.pid)
754
+ assert.ok(coordinatorProcess)
755
+ assert.equal(coordinator.lifecycleRole, "retired")
756
+ assert.equal(daemon.guardian?.processes.get("release:v1:beacon"), coordinatorProcess, "retirement refresh must preserve exact guardian event routing")
757
+ const exited = once(coordinatorProcess, "exit")
758
+
759
+ process.kill(-coordinator.pid, "SIGKILL")
760
+ const [exit] = await exited
761
+ const stopped = coordinatorProcess.status()
762
+
763
+ assert.equal(exit.code, null)
764
+ assert.equal(exit.id, "beacon")
765
+ assert.equal(exit.signal, "SIGKILL")
766
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
767
+ assert.equal(stopped.lifecycleRole, "retired")
768
+ assert.equal(stopped.pid, undefined)
769
+ assert.equal(stopped.restarts, 0)
770
+ assert.equal(stopped.state, "stopped")
771
+ assert.equal(coordinatorProcess.restartTimer, undefined, "retired process must not queue a restart")
772
+ } finally {
773
+ socket?.close()
774
+ await daemon.shutdown()
775
+ await fs.rm(fixture.root, {force: true, recursive: true})
776
+ }
777
+ })
778
+
508
779
  test("multiple retired jobs generations keep distinct endpoints and live references until completion", async () => {
509
780
  const fixture = await createFixture({handoffService: true, handoffServiceQuiet: true, webDependsOnService: true})
510
781
  const daemon = await startDaemon(fixture.config)
@@ -819,7 +1090,11 @@ test("persisted daemon state excludes process commands, environment values, and
819
1090
 
820
1091
  try {
821
1092
  await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
822
- await waitFor(() => Boolean(daemon.activeRelease?.getProcess("web")?.status().logs.some((entry) => entry.line === secret)))
1093
+ const webProcess = daemon.activeRelease?.getProcess("web")
1094
+
1095
+ assert.ok(webProcess)
1096
+ await recordedLogLine(webProcess, secret)
1097
+ assert.ok(webProcess.status().logs.some((entry) => entry.line === secret), "secret output must be retained before persistence")
823
1098
 
824
1099
  daemon.persistState()
825
1100
  await waitFor(async () => (await fs.readFile(fixture.statePath, "utf8")).includes('"activeReleaseId": "v1"'))
@@ -1358,19 +1633,30 @@ test("deploy can ensure the daemon before sending the release command", async ()
1358
1633
  })
1359
1634
 
1360
1635
  /**
1361
- * @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
1362
- * @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, serviceQuietPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
1636
+ * @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceActivate?: boolean, handoffServiceActivateFailure?: boolean | string, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
1637
+ * @returns {Promise<{activationGatePath: string, config: import("../src/config.js").RollbridgeConfig, lifecycleLogPath: string, retirementGatePath: string, root: string, serviceLogPath: string, serviceQuietPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
1363
1638
  */
1364
1639
  async function createFixture(options = {}) {
1365
1640
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-test-"))
1366
1641
  const serviceLogPath = path.join(root, "service.log")
1367
1642
  const serviceQuietPath = path.join(root, "service.quiet")
1643
+ const lifecycleLogPath = path.join(root, "service.lifecycle")
1644
+ const activationGatePath = path.join(root, "activation.allow")
1645
+ const retirementGatePath = path.join(root, "retirement.allow")
1368
1646
  const singletonLogPath = path.join(root, "singleton.log")
1369
1647
  const statePath = path.join(root, "rollbridge.state.json")
1370
1648
  /** @type {Array<Record<string, import("../src/json.js").JsonValue>>} */
1371
1649
  const processes = []
1372
1650
 
1373
1651
  if (options.includeService || options.handoffService) {
1652
+ const activationFailureRelease = typeof options.handoffServiceActivateFailure === "string" ? options.handoffServiceActivateFailure : "v2"
1653
+ const lifecycle = options.handoffServiceActivate ? {
1654
+ activateCommand: `${options.handoffServiceActivateFailure ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
1655
+ quietCommand: `${options.handoffServiceQuietFailure ? `[ -f ${JSON.stringify(retirementGatePath)} ] || exit 23; ` : ""}printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1656
+ } : options.handoffServiceQuiet || options.handoffServiceQuietFailure ? {
1657
+ quietCommand: options.handoffServiceQuietFailure ? "exit 23" : `printf '%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(serviceQuietPath)}`
1658
+ } : undefined
1659
+
1374
1660
  processes.push({
1375
1661
  command: `${JSON.stringify(process.execPath)} ${JSON.stringify(serviceAppPath)} --release={{releaseId}}`,
1376
1662
  ...(options.handoffService ? {deployStrategy: "handoff"} : {}),
@@ -1378,7 +1664,7 @@ async function createFixture(options = {}) {
1378
1664
  ROLLBRIDGE_SERVICE_LOG: serviceLogPath
1379
1665
  },
1380
1666
  id: "beacon",
1381
- ...(options.handoffServiceQuiet || options.handoffServiceQuietFailure ? {lifecycle: {quietCommand: options.handoffServiceQuietFailure ? "exit 23" : `printf '%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(serviceQuietPath)}`}} : {}),
1667
+ ...(lifecycle ? {lifecycle} : {}),
1382
1668
  policy: "service",
1383
1669
  port: options.handoffService ? {from: 15000, to: 15099} : {from: 0, to: 0},
1384
1670
  restartDelayMs: 50
@@ -1463,10 +1749,18 @@ async function createFixture(options = {}) {
1463
1749
  host: options.proxyHost || "127.0.0.1",
1464
1750
  port: 0
1465
1751
  },
1466
- ...(options.persistState ? {statePath} : {})
1752
+ ...((options.persistState || options.handoffServiceActivate) ? {ownerRecovery: {reconnectGraceMs: 30000}, statePath} : {})
1467
1753
  })
1468
1754
 
1469
- return {config, root, serviceLogPath, serviceQuietPath, singletonLogPath, statePath}
1755
+ return {activationGatePath, config, lifecycleLogPath, retirementGatePath, root, serviceLogPath, serviceQuietPath, singletonLogPath, statePath}
1756
+ }
1757
+
1758
+ /**
1759
+ * @param {string} lifecycleLogPath - Fixture lifecycle event file.
1760
+ * @returns {Promise<string[]>} Ordered lifecycle events.
1761
+ */
1762
+ async function lifecycleEvents(lifecycleLogPath) {
1763
+ return (await fs.readFile(lifecycleLogPath, "utf8")).trim().split("\n").filter(Boolean)
1470
1764
  }
1471
1765
 
1472
1766
  /**
@@ -1604,6 +1898,39 @@ async function waitFor(callback, timeoutMs = 3000) {
1604
1898
  throw new Error("Timed out waiting for condition")
1605
1899
  }
1606
1900
 
1901
+ /**
1902
+ * Resolves from retained output or the exact event that records it, and rejects if the
1903
+ * supervised process exits first.
1904
+ * @param {import("../src/managed-process.js").default} processInstance - Exact managed process.
1905
+ * @param {string} line - Complete output line to observe.
1906
+ * @returns {Promise<import("../src/managed-process.js").ManagedProcessLog>} Recorded log entry.
1907
+ */
1908
+ async function recordedLogLine(processInstance, line) {
1909
+ const retained = processInstance.status().logs.find((entry) => entry.line === line)
1910
+
1911
+ if (retained) return retained
1912
+ return await new Promise((resolve, reject) => {
1913
+ /** @param {import("../src/managed-process.js").ManagedProcessLog} entry - Newly retained output. */
1914
+ const onLog = (entry) => {
1915
+ if (entry.line !== line) return
1916
+ cleanup()
1917
+ resolve(entry)
1918
+ }
1919
+ /** @param {{code: number | null, signal: import("node:child_process").ChildProcess["signalCode"]}} exit - Exact process exit. */
1920
+ const onExit = (exit) => {
1921
+ cleanup()
1922
+ reject(new Error(`Process ${processInstance.id} exited before recording expected output ${JSON.stringify(line)}: ${JSON.stringify(exit)}`))
1923
+ }
1924
+ const cleanup = () => {
1925
+ processInstance.off("log", onLog)
1926
+ processInstance.off("exit", onExit)
1927
+ }
1928
+
1929
+ processInstance.on("log", onLog)
1930
+ processInstance.once("exit", onExit)
1931
+ })
1932
+ }
1933
+
1607
1934
  /**
1608
1935
  * @param {RollbridgeDaemon} daemon - Daemon.
1609
1936
  * @param {string} processId - Process id within the active release.