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.
@@ -7,9 +7,10 @@ import {normalizeConfig} from "../src/config.js"
7
7
 
8
8
  /**
9
9
  * @param {import("../src/json.js").JsonValue} webProcess - The single proxied process definition.
10
+ * @param {() => boolean} [shouldStart] - Whether process starts remain allowed.
10
11
  * @returns {ReleaseGroup} A release group ready for buildProcess.
11
12
  */
12
- function buildRelease(webProcess) {
13
+ function buildRelease(webProcess, shouldStart = () => true) {
13
14
  const config = normalizeConfig({
14
15
  application: "demo",
15
16
  control: {path: "/tmp/rollbridge-release-group.sock"},
@@ -17,7 +18,7 @@ function buildRelease(webProcess) {
17
18
  proxy: {host: "127.0.0.1", port: 0}
18
19
  })
19
20
 
20
- return new ReleaseGroup({config, logger: () => {}, releaseId: "v1", releasePath: "/tmp/rel", revision: "v1"})
21
+ return new ReleaseGroup({config, logger: () => {}, releaseId: "v1", releasePath: "/tmp/rel", revision: "v1", shouldStart})
21
22
  }
22
23
 
23
24
  test("templates interpolate values from the daemon environment", () => {
@@ -78,3 +79,19 @@ test("a referenced daemon environment variable that is unset fails fast", () =>
78
79
  /Missing template value for \{\{env.ROLLBRIDGE_ENV_MISSING\}\}/
79
80
  )
80
81
  })
82
+
83
+ test("committed generation restoration does not start after shutdown begins", async () => {
84
+ const release = buildRelease({command: "run web", id: "web", policy: "proxied", port: {from: 0, to: 0}}, () => false)
85
+ const process = release.buildProcess(release.config.processes[0])
86
+ let starts = 0
87
+
88
+ release.state = "draining"
89
+ process.start = async () => {
90
+ starts += 1
91
+ throw new Error("process started after shutdown")
92
+ }
93
+ release.processes.set("web", process)
94
+
95
+ await assert.rejects(() => release.restartCommittedGeneration(), /shutting down/)
96
+ assert.equal(starts, 0)
97
+ })
@@ -10,6 +10,7 @@ import path from "node:path"
10
10
  import test from "node:test"
11
11
  import {fileURLToPath} from "node:url"
12
12
  import {sendControlCommand} from "../src/control-client.js"
13
+ import GuardianClient from "../src/guardian-client.js"
13
14
 
14
15
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
15
16
  const dummyAppPath = path.join(repoRoot, "test", "fixtures", "dummy-app.js")
@@ -23,12 +24,14 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
23
24
  const logPath = path.join(root, "daemon.log")
24
25
  const pidPath = path.join(root, "daemon.pid")
25
26
  const runtimePath = path.join(root, "runtime")
27
+ const statePath = path.join(root, "state.json")
26
28
 
27
29
  try {
28
30
  await Promise.all([prepareRelease(releaseA, true), prepareRelease(releaseB, true)])
29
31
  await fs.writeFile(configPath, `export default ${JSON.stringify({
30
32
  application: "runtime-retention-test",
31
33
  control: {path: socketPath},
34
+ ownerRecovery: {reconnectGraceMs: 50},
32
35
  processes: [{
33
36
  command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
34
37
  health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
@@ -36,7 +39,8 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
36
39
  policy: "proxied",
37
40
  port: {from: 0, to: 0}
38
41
  }],
39
- proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0}
42
+ proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0},
43
+ statePath
40
44
  }, null, 2)}\n`)
41
45
 
42
46
  await runReleaseCli(releaseA, [
@@ -52,7 +56,7 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
52
56
  "--daemon-runtime-path", runtimePath
53
57
  ])
54
58
 
55
- await fs.rm(path.join(releaseA, "node_modules"), {recursive: true})
59
+ await fs.rm(releaseA, {recursive: true})
56
60
 
57
61
  const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
58
62
  const proxyPort = /** @type {{port: number}} */ (status.proxy).port
@@ -66,14 +70,67 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
66
70
  assert.ok(!runtime.path.startsWith(releaseA), `runtime must be outside release A: ${runtime.path}`)
67
71
  assert.equal(response.status, 200)
68
72
  assert.equal(await response.text(), "deferred runtime loaded\n")
73
+ assert.equal(typeof status.daemonPid, "number")
74
+ const daemonPid = /** @type {number} */ (status.daemonPid)
75
+
76
+ process.kill(daemonPid, "SIGKILL")
77
+ const recoveredPid = await waitForChangedPid(pidPath, daemonPid)
78
+ const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
79
+
80
+ assert.equal(recovered.daemonPid, recoveredPid)
81
+ assert.equal(recovered.activeReleaseId, "B")
69
82
  } finally {
70
83
  try {
71
84
  await sendControlCommand({command: {command: "shutdown"}, path: socketPath})
72
85
  } catch {
73
86
  const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
74
- if (Number.isInteger(pid)) process.kill(pid, "SIGKILL")
87
+ if (Number.isInteger(pid)) killProcessIfAlive(pid)
75
88
  }
89
+ await stopGuardian(statePath)
90
+
91
+ await fs.rm(root, {force: true, recursive: true})
92
+ }
93
+ })
94
+
95
+ test("ensure-daemon resolves an explicit relative config before changing to its durable directory", async () => {
96
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-relative-config-"))
97
+ const release = path.join(root, "release")
98
+ const configDirectory = path.join(release, "configs")
99
+ const configPath = path.join(configDirectory, "rollbridge.js")
100
+ const pidPath = path.join(root, "daemon.pid")
101
+ const socketPath = path.join(root, "control.sock")
102
+ const statePath = path.join(root, "state.json")
103
+
104
+ try {
105
+ await prepareRelease(release, false)
106
+ await fs.mkdir(configDirectory)
107
+ await fs.writeFile(configPath, `export default ${JSON.stringify({
108
+ ...basicConfig(path.relative(configDirectory, socketPath)),
109
+ ownerRecovery: {reconnectGraceMs: 50},
110
+ processes: [{
111
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
112
+ health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
113
+ id: "web",
114
+ policy: "proxied",
115
+ port: {from: 0, to: 0}
116
+ }],
117
+ statePath: path.relative(configDirectory, statePath)
118
+ }, null, 2)}\n`)
119
+ await runReleaseCli(release, [
120
+ "deploy", "--ensure-daemon", "--config", path.relative(release, configPath),
121
+ "--release-path", release, "--release-id", "relative-config",
122
+ "--daemon-log-path", path.join(root, "daemon.log"), "--daemon-pid-path", pidPath,
123
+ "--daemon-runtime-path", path.join(root, "runtime"), "--daemon-start-timeout-ms", "1000"
124
+ ])
125
+ const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
126
+
127
+ assert.equal(status.activeReleaseId, "relative-config")
128
+ } finally {
129
+ await sendControlCommand({command: {command: "shutdown"}, path: socketPath}).catch(() => undefined)
130
+ await stopGuardian(statePath)
131
+ const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
76
132
 
133
+ if (Number.isInteger(pid)) killProcessIfAlive(pid)
77
134
  await fs.rm(root, {force: true, recursive: true})
78
135
  }
79
136
  })
@@ -349,7 +406,7 @@ function basicConfig(socketPath) {
349
406
  */
350
407
  async function runReleaseCli(releasePath, args) {
351
408
  const binPath = path.join(releasePath, "node_modules", "rollbridge", "bin", "rollbridge")
352
- const child = spawn(process.execPath, [binPath, ...args], {stdio: ["ignore", "pipe", "pipe"]})
409
+ const child = spawn(process.execPath, [binPath, ...args], {cwd: releasePath, stdio: ["ignore", "pipe", "pipe"]})
353
410
  let output = ""
354
411
 
355
412
  child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
@@ -358,3 +415,63 @@ async function runReleaseCli(releasePath, args) {
358
415
 
359
416
  if (code !== 0) throw new Error(output)
360
417
  }
418
+
419
+ /**
420
+ * @param {string} pidPath - Daemon PID file.
421
+ * @param {number} previousPid - Exited daemon PID.
422
+ * @returns {Promise<number>} Recovered daemon PID.
423
+ */
424
+ async function waitForChangedPid(pidPath, previousPid) {
425
+ const deadline = Date.now() + 5000
426
+
427
+ while (Date.now() < deadline) {
428
+ const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
429
+
430
+ if (Number.isInteger(pid) && pid !== previousPid) return pid
431
+ await new Promise((resolve) => setTimeout(resolve, 25))
432
+ }
433
+ throw new Error(`Timed out waiting for ${pidPath} to publish a recovered daemon PID`)
434
+ }
435
+
436
+ /** @param {number} pid - Exact fixture process PID. */
437
+ function killProcessIfAlive(pid) {
438
+ try {
439
+ process.kill(pid, "SIGKILL")
440
+ } catch (error) {
441
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
442
+ }
443
+ }
444
+
445
+ /** @param {string} statePath - Owner-recovery state path. */
446
+ async function stopGuardian(statePath) {
447
+ try {
448
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"))
449
+ const identity = state.recovery?.guardian
450
+ const pid = identity?.pid
451
+
452
+ if (identity?.socketPath && identity.token) {
453
+ const client = new GuardianClient(identity)
454
+
455
+ try {
456
+ await client.connect()
457
+ for (const entry of await client.inventory()) {
458
+ if (entry.status.pid) {
459
+ try { process.kill(-entry.status.pid, "SIGKILL") } catch (error) {
460
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
461
+ }
462
+ }
463
+ }
464
+ } finally {
465
+ client.disconnect()
466
+ }
467
+ }
468
+ if (typeof pid === "number") {
469
+ const command = (await fs.readFile(`/proc/${pid}/cmdline`, "utf8")).replaceAll("\0", " ")
470
+
471
+ if (!command.includes("process-guardian.js") || !command.includes(statePath)) throw new Error(`Refusing to stop unverified fixture guardian pid ${pid}`)
472
+ process.kill(pid, "SIGKILL")
473
+ }
474
+ } catch (error) {
475
+ if (!error || typeof error !== "object" || !("code" in error) || !["ENOENT", "ESRCH"].includes(String(error.code))) throw error
476
+ }
477
+ }
@@ -525,6 +525,27 @@ test("opt-in generation lifecycle retires the old generation before activating a
525
525
  }
526
526
  })
527
527
 
528
+ test("manual restart reaches the active handoff coordinator and restores its lifecycle role", async () => {
529
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, webDependsOnService: true})
530
+ const daemon = await startDaemon(fixture.config)
531
+
532
+ try {
533
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
534
+ const before = statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.pid
535
+ const result = await daemon.restartProcesses({processId: "beacon"})
536
+ const after = statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.pid
537
+
538
+ assert.deepEqual(result, {restarted: ["beacon"]})
539
+ assert.ok(before)
540
+ assert.ok(after)
541
+ assert.notEqual(after, before)
542
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v1"])
543
+ } finally {
544
+ await daemon.shutdown()
545
+ await fs.rm(fixture.root, {force: true, recursive: true})
546
+ }
547
+ })
548
+
528
549
  test("generation commit is durable before awaited post-transition work", async () => {
529
550
  const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, includeSingleton: true, webDependsOnService: true})
530
551
  const daemon = await startDaemon(fixture.config)
@@ -639,25 +660,248 @@ test("retirement failure retains the exact transition, blocks other deploys, and
639
660
  }
640
661
  })
641
662
 
642
- test("candidate activation failure never reactivates old and exact resume activates only the candidate", async () => {
663
+ test("candidate activation failure reports restoration failure and exact recovery clears the fence", async () => {
643
664
  const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
644
665
  const daemon = await startDaemon(fixture.config)
645
666
 
646
667
  try {
647
668
  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/)
669
+ const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
670
+ const reactivate = incumbentCoordinator?.reactivateStrict.bind(incumbentCoordinator)
671
+
672
+ assert.ok(incumbentCoordinator && reactivate)
673
+ incumbentCoordinator.reactivateStrict = async () => { throw new Error("incumbent restoration rejected") }
674
+ await assert.rejects(
675
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
676
+ error => {
677
+ assert.ok(error instanceof AggregateError)
678
+ assert.match(error.message, /activate command exited non-zero/)
679
+ assert.match(error.message, /incumbent v1 restoration failed: incumbent restoration rejected/i)
680
+ return true
681
+ }
682
+ )
649
683
 
650
684
  const failed = daemon.status()
651
685
 
652
686
  assert.equal(failed.activeReleaseId, "v1")
653
- assert.equal(failed.generationTransition?.phase, "activating_candidate")
654
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
687
+ assert.equal(failed.generationTransition?.phase, "restoring_previous")
688
+ assert.match(String(failed.generationTransition?.activationError), /activate command exited non-zero/)
689
+ assert.match(String(failed.generationTransition?.compensationError), /incumbent restoration rejected/)
690
+ const failedEvents = daemon.eventLog.recent()
691
+ const activationEvent = failedEvents.find((event) => event.message === "release generation activation failed")
692
+ const restorationEvent = failedEvents.find((event) => event.message === "release generation compensation restoration failed")
693
+
694
+ assert.match(String(activationEvent?.data.error), /activate command exited non-zero/)
695
+ assert.match(String(restorationEvent?.data.activationError), /activate command exited non-zero/)
696
+ assert.match(String(restorationEvent?.data.error), /incumbent restoration rejected/)
697
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2"])
698
+ await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
655
699
 
656
- await fs.writeFile(fixture.activationGatePath, "allow\n")
657
- await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
700
+ incumbentCoordinator.reactivateStrict = reactivate
701
+ const recovery = await sendControlCommand({
702
+ command: {
703
+ command: "recover-generation-transition",
704
+ previousReleaseId: "v1",
705
+ releaseId: "v2",
706
+ releasePath: fixture.root,
707
+ revision: "v2"
708
+ },
709
+ path: fixture.config.control.path
710
+ })
658
711
 
659
- assert.equal(daemon.status().activeReleaseId, "v2")
660
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
712
+ assert.equal(recovery.recoveryStatus, "recovered")
713
+ assert.equal(daemon.status().activeReleaseId, "v1")
714
+ assert.equal(daemon.status().generationTransition, undefined)
715
+ const persisted = /** @type {{generationTransition?: import("../src/json.js").JsonValue} | undefined} */ (await readState(fixture.statePath))
716
+
717
+ assert.equal(persisted?.generationTransition, undefined)
718
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
719
+
720
+ const idempotent = await sendControlCommand({
721
+ command: {
722
+ command: "recover-generation-transition",
723
+ previousReleaseId: "v1",
724
+ releaseId: "v2",
725
+ releasePath: fixture.root,
726
+ revision: "v2"
727
+ },
728
+ path: fixture.config.control.path
729
+ })
730
+
731
+ assert.equal(idempotent.recoveryStatus, "already_recovered")
732
+ await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
733
+ await assert.rejects(
734
+ () => sendControlCommand({
735
+ command: {
736
+ command: "recover-generation-transition",
737
+ previousReleaseId: "v1",
738
+ releaseId: "v3",
739
+ releasePath: fixture.root,
740
+ revision: "v3"
741
+ },
742
+ path: fixture.config.control.path
743
+ }),
744
+ /not a safe failed pre-commit transition/i
745
+ )
746
+ } finally {
747
+ await daemon.shutdown()
748
+ await fs.rm(fixture.root, {force: true, recursive: true})
749
+ }
750
+ })
751
+
752
+ test("candidate activation failure compensates to the incumbent and admits a different later release", async () => {
753
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
754
+ const daemon = await startDaemon(fixture.config)
755
+
756
+ try {
757
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
758
+ await assert.rejects(
759
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
760
+ /activate command exited non-zero.*compensation restored incumbent v1 as authoritative and retired failed candidate v2/i
761
+ )
762
+
763
+ const compensated = daemon.status()
764
+
765
+ assert.equal(compensated.activeReleaseId, "v1")
766
+ assert.equal(compensated.generationTransition, undefined)
767
+ assert.equal(await fetchText(daemon, "/release"), "v1")
768
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state, "running")
769
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
770
+
771
+ await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
772
+
773
+ assert.equal(daemon.status().activeReleaseId, "v3")
774
+ assert.equal(await fetchText(daemon, "/release"), "v3")
775
+ } finally {
776
+ await daemon.shutdown()
777
+ await fs.rm(fixture.root, {force: true, recursive: true})
778
+ }
779
+ })
780
+
781
+ test("ambiguous candidate activation retires the candidate before reactivating the incumbent", async () => {
782
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateAmbiguousFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
783
+ const daemon = await startDaemon(fixture.config)
784
+
785
+ try {
786
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
787
+ await assert.rejects(
788
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
789
+ /activate command exited non-zero.*compensation restored incumbent v1 as authoritative and retired failed candidate v2/i
790
+ )
791
+
792
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), [
793
+ "activate:v1",
794
+ "retire:v1",
795
+ "activate:v2",
796
+ "retire:v2",
797
+ "activate:v1"
798
+ ])
799
+ assert.equal(daemon.status().activeReleaseId, "v1")
800
+ assert.equal(daemon.status().generationTransition, undefined)
801
+ } finally {
802
+ await daemon.shutdown()
803
+ await fs.rm(fixture.root, {force: true, recursive: true})
804
+ }
805
+ })
806
+
807
+ test("candidate activation recovery reverses a worker-specific quiet hook before reporting active", async () => {
808
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true, workerReactivationLifecycle: true})
809
+ const daemon = await startDaemon(fixture.config)
810
+
811
+ try {
812
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
813
+ await assert.rejects(
814
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
815
+ /compensation restored incumbent v1 as authoritative/i
816
+ )
817
+
818
+ const events = await lifecycleEvents(fixture.lifecycleLogPath)
819
+ const candidateRetired = events.indexOf("worker-retire:v2")
820
+ const workerReactivated = events.indexOf("worker-reactivate:v1")
821
+
822
+ assert.ok(candidateRetired >= 0, JSON.stringify(events))
823
+ assert.ok(workerReactivated > candidateRetired, JSON.stringify(events))
824
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state, "running")
825
+ assert.equal(daemon.status().activeReleaseId, "v1")
826
+ assert.equal(daemon.status().generationTransition, undefined)
827
+ } finally {
828
+ await daemon.shutdown()
829
+ await fs.rm(fixture.root, {force: true, recursive: true})
830
+ }
831
+ })
832
+
833
+ test("candidate activation recovery keeps the fence when a worker-specific resume hook fails", async () => {
834
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true, workerReactivationFailure: true, workerReactivationLifecycle: true})
835
+ const daemon = await startDaemon(fixture.config)
836
+
837
+ try {
838
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
839
+ await assert.rejects(
840
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
841
+ error => {
842
+ const failure = /** @type {Error} */ (error)
843
+
844
+ assert.match(failure.message, /activate command exited non-zero/)
845
+ assert.match(failure.message, /reactivate command exited non-zero/)
846
+ return true
847
+ }
848
+ )
849
+
850
+ const status = daemon.status()
851
+ const restorationEvent = daemon.eventLog.recent().find((event) => event.message === "release generation compensation restoration failed")
852
+
853
+ assert.equal(status.activeReleaseId, "v1")
854
+ assert.equal(status.generationTransition?.phase, "restoring_previous")
855
+ assert.match(String(status.generationTransition?.activationError), /activate command exited non-zero/)
856
+ assert.match(String(status.generationTransition?.compensationError), /reactivate command exited non-zero/)
857
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state, "quiesced")
858
+ assert.match(String(restorationEvent?.data.activationError), /activate command exited non-zero/)
859
+ assert.match(String(restorationEvent?.data.error), /reactivate command exited non-zero/)
860
+ await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
861
+ } finally {
862
+ await daemon.shutdown()
863
+ await fs.rm(fixture.root, {force: true, recursive: true})
864
+ }
865
+ })
866
+
867
+ test("compensation keeps the fence when the cleared checkpoint cannot be persisted", async () => {
868
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
869
+ const daemon = await startDaemon(fixture.config)
870
+
871
+ try {
872
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
873
+ const checkpoint = daemon.checkpointGenerationTransition.bind(daemon)
874
+
875
+ daemon.checkpointGenerationTransition = async () => {
876
+ if (!daemon.generationTransition) throw new Error("cleared checkpoint unavailable")
877
+ await checkpoint()
878
+ }
879
+ await assert.rejects(
880
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
881
+ /activate command exited non-zero.*compensation checkpoint clear failed: cleared checkpoint unavailable/i
882
+ )
883
+
884
+ assert.equal(daemon.status().activeReleaseId, "v1")
885
+ assert.equal(daemon.status().generationTransition?.phase, "restoring_previous")
886
+ const persisted = /** @type {{generationTransition?: {phase?: string}} | undefined} */ (await readState(fixture.statePath))
887
+
888
+ assert.equal(persisted?.generationTransition?.phase, "restoring_previous")
889
+ await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
890
+
891
+ daemon.checkpointGenerationTransition = checkpoint
892
+ const recovery = await sendControlCommand({
893
+ command: {
894
+ command: "recover-generation-transition",
895
+ previousReleaseId: "v1",
896
+ releaseId: "v2",
897
+ releasePath: fixture.root,
898
+ revision: "v2"
899
+ },
900
+ path: fixture.config.control.path
901
+ })
902
+
903
+ assert.equal(recovery.recoveryStatus, "recovered")
904
+ assert.equal(daemon.status().generationTransition, undefined)
661
905
  } finally {
662
906
  await daemon.shutdown()
663
907
  await fs.rm(fixture.root, {force: true, recursive: true})
@@ -670,6 +914,10 @@ test("unresolved generation transition fences stop, restart, and rollback mutati
670
914
 
671
915
  try {
672
916
  await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
917
+ const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
918
+
919
+ assert.ok(incumbentCoordinator)
920
+ incumbentCoordinator.reactivateStrict = async () => { throw new Error("incumbent restoration rejected") }
673
921
  await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
674
922
 
675
923
  await assert.rejects(() => daemon.stopRelease("v2"), /cannot stop.*generation transition.*unresolved/i)
@@ -677,9 +925,7 @@ test("unresolved generation transition fences stop, restart, and rollback mutati
677
925
  await assert.rejects(() => daemon.rollback({releaseId: "v2"}), /cannot rollback.*generation transition.*unresolved/i)
678
926
 
679
927
  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")
928
+ assert.equal(await fetchText(daemon, "/release"), "v1")
683
929
  } finally {
684
930
  await daemon.shutdown()
685
931
  await fs.rm(fixture.root, {force: true, recursive: true})
@@ -1633,7 +1879,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
1633
1879
  })
1634
1880
 
1635
1881
  /**
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.
1882
+ * @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceActivate?: boolean, handoffServiceActivateAmbiguousFailure?: 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, workerReactivationFailure?: boolean, workerReactivationLifecycle?: boolean, workerStopDelayMs?: number}} [options] - Fixture options.
1637
1883
  * @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.
1638
1884
  */
1639
1885
  async function createFixture(options = {}) {
@@ -1651,7 +1897,7 @@ async function createFixture(options = {}) {
1651
1897
  if (options.includeService || options.handoffService) {
1652
1898
  const activationFailureRelease = typeof options.handoffServiceActivateFailure === "string" ? options.handoffServiceActivateFailure : "v2"
1653
1899
  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)}`,
1900
+ activateCommand: `${options.handoffServiceActivateFailure && !options.handoffServiceActivateAmbiguousFailure ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}${options.handoffServiceActivateAmbiguousFailure ? `; [ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24` : ""}`,
1655
1901
  quietCommand: `${options.handoffServiceQuietFailure ? `[ -f ${JSON.stringify(retirementGatePath)} ] || exit 23; ` : ""}printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1656
1902
  } : options.handoffServiceQuiet || options.handoffServiceQuietFailure ? {
1657
1903
  quietCommand: options.handoffServiceQuietFailure ? "exit 23" : `printf '%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(serviceQuietPath)}`
@@ -1692,6 +1938,10 @@ async function createFixture(options = {}) {
1692
1938
  processes.push({
1693
1939
  command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`process.on('SIGTERM', () => setTimeout(() => process.exit(0), ${options.workerStopDelayMs || 0})); setInterval(() => {}, 1000)`)}`,
1694
1940
  id: "worker",
1941
+ ...(options.workerReactivationLifecycle ? {lifecycle: {
1942
+ quietCommand: `printf 'worker-retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
1943
+ reactivateCommand: `${options.workerReactivationFailure ? "exit 25; " : ""}printf 'worker-reactivate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1944
+ }} : {}),
1695
1945
  nonBlockingDrain: true,
1696
1946
  policy: "companion"
1697
1947
  })
@@ -0,0 +1,41 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs"
4
+
5
+ /**
6
+ * Reports whether an exact fixture process can still run. Linux keeps exited children in
7
+ * procfs until they are reaped, so kill(2) alone cannot distinguish a zombie from a live process.
8
+ * @param {number} pid - Exact fixture process PID.
9
+ * @returns {boolean} Whether the process can still run.
10
+ */
11
+ export function isProcessRunning(pid) {
12
+ try {
13
+ process.kill(pid, 0)
14
+ } catch (error) {
15
+ if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false
16
+ throw error
17
+ }
18
+
19
+ if (process.platform !== "linux") return true
20
+
21
+ try {
22
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8")
23
+ const state = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[0]
24
+
25
+ return state !== "Z" && state !== "X"
26
+ } catch (error) {
27
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false
28
+ throw error
29
+ }
30
+ }
31
+
32
+ /**
33
+ * @param {number} pid - Exact fixture process PID.
34
+ * @param {number} [timeoutMs] - Bounded exit wait.
35
+ */
36
+ export async function waitForProcessExit(pid, timeoutMs = 5000) {
37
+ const deadline = Date.now() + timeoutMs
38
+
39
+ while (isProcessRunning(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10))
40
+ if (isProcessRunning(pid)) throw new Error(`Timed out waiting for process ${pid} to exit`)
41
+ }