rollbridge 0.1.55 → 0.1.56

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.
@@ -0,0 +1,14 @@
1
+ ### Fixed
2
+
3
+ - Stopped releases no longer carry a stale connection count into owner
4
+ replacement. When a drain times out the release's local connection count was
5
+ frozen non-zero, persisted into the daemon state, and replayed into every
6
+ successor daemon's owner-replace handoff. The successor daemon does not
7
+ retain the stopped release, so the replay failed the candidate with
8
+ `Incumbent listener reported unknown release <id>` and deadlocked every
9
+ subsequent deployment. A release that reaches terminal `stopped` now
10
+ abandons its local connection count (it owns no live proxy listener),
11
+ `restore()` no longer re-adopts a persisted count for a stopped release,
12
+ the serialized owner handoff skips stopped releases, and a non-zero report
13
+ for a release the daemon does not retain is reconciled to zero with a log
14
+ line instead of throwing, so a stale count can never wedge the node again.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.55",
3
+ "version": "0.1.56",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/daemon.js CHANGED
@@ -832,7 +832,22 @@ export default class RollbridgeDaemon {
832
832
  setListenerConnectionSource(sourceId, releaseId, connections) {
833
833
  const release = this.releases.get(releaseId)
834
834
 
835
- if (!release && (connections.http > 0 || connections.websocket > 0)) throw new Error(`Incumbent listener reported unknown release ${releaseId}`)
835
+ if (!release) {
836
+ if (connections.http + connections.websocket > 0) {
837
+ // A live proxy connection is always attributed to a release that still listens. A
838
+ // non-zero report for a release this daemon does not retain is a stale count frozen by
839
+ // a drain timeout in an earlier owner; accounting for it here would deadlock every
840
+ // future handoff, so reconcile it to zero instead of failing the owner replacement.
841
+ this.logger("dropping stale connection state for unretained release", {connections, releaseId, sourceId})
842
+ }
843
+ const sourceReleases = this.listenerConnectionSources.get(sourceId)
844
+
845
+ if (sourceReleases) {
846
+ sourceReleases.delete(releaseId)
847
+ if (sourceReleases.size === 0) this.listenerConnectionSources.delete(sourceId)
848
+ }
849
+ return
850
+ }
836
851
  const sourceReleases = this.listenerConnectionSources.get(sourceId) || new Map()
837
852
 
838
853
  if (connections.http + connections.websocket === 0) sourceReleases.delete(releaseId)
@@ -855,10 +870,23 @@ export default class RollbridgeDaemon {
855
870
 
856
871
  /** @returns {Record<string, Record<string, {http: number, websocket: number}>>} Exact live listener sources. */
857
872
  serializedListenerConnectionSources() {
858
- const sources = new Map([...this.listenerConnectionSources].map(([sourceId, releases]) => [sourceId, new Map(releases)]))
873
+ const sources = new Map()
874
+
875
+ for (const [sourceId, releases] of this.listenerConnectionSources) {
876
+ const retained = new Map()
877
+
878
+ for (const [releaseId, connections] of releases) {
879
+ // A stopped release owns no live listener; its count is a stale drain timeout. Carrying
880
+ // it into a successor daemon would report a connection the successor cannot attribute.
881
+ if (this.releases.get(releaseId)?.state === "stopped") continue
882
+ retained.set(releaseId, connections)
883
+ }
884
+ if (retained.size > 0) sources.set(sourceId, retained)
885
+ }
859
886
  const local = new Map()
860
887
 
861
888
  for (const release of this.releases.values()) {
889
+ if (release.state === "stopped") continue
862
890
  const connections = release.localConnections()
863
891
 
864
892
  if (connections.http + connections.websocket > 0) local.set(release.releaseId, connections)
@@ -196,7 +196,9 @@ export default class ReleaseGroup extends EventEmitter {
196
196
  this.drainStartedAt = snapshot.drainStartedAt
197
197
  this.retirementError = snapshot.retirementError
198
198
  this.stoppedAt = snapshot.stoppedAt
199
- this.setTransferredConnections(snapshot.connections)
199
+ // A stopped release no longer owns a proxy listener, so a persisted nonzero count is a
200
+ // stale drain timeout; re-adopting it would poison this daemon's handoff to the next one.
201
+ if (snapshot.state !== "stopped") this.setTransferredConnections(snapshot.connections)
200
202
 
201
203
  for (const processStatus of snapshot.processes) {
202
204
  const baseId = processStatus.id.replace(/#\d+$/, "")
@@ -625,6 +627,22 @@ export default class ReleaseGroup extends EventEmitter {
625
627
  }
626
628
  }
627
629
 
630
+ /**
631
+ * Abandons every connection this daemon still counts for the release. A release that
632
+ * reaches terminal `stopped` owns no live proxy listener, so any remaining local count
633
+ * is a stale drain timeout. Clearing it here (rather than relying on each in-flight
634
+ * connection to release) keeps the invariant "a stopped release holds no connections"
635
+ * intact for state persistence, owner-replacement handoff, and future daemons.
636
+ */
637
+ abandonLocalConnections() {
638
+ if (this.state !== "stopped") return
639
+ if (this.connectionCount === 0 && this.transferredConnections.http + this.transferredConnections.websocket === 0) return
640
+ this.connections = {http: 0, websocket: 0}
641
+ this.transferredConnections = {http: 0, websocket: 0}
642
+ this.connectionCount = 0
643
+ this.emit("drained")
644
+ }
645
+
628
646
  /**
629
647
  * Reconciles connections still owned by a prior daemon listener.
630
648
  * @param {ReleaseConnections} connections - Exact incumbent listener counts.
@@ -721,6 +739,7 @@ export default class ReleaseGroup extends EventEmitter {
721
739
  this.state = "stopped"
722
740
  this.stoppedAt = new Date().toISOString()
723
741
  this.releasePortReservations()
742
+ this.abandonLocalConnections()
724
743
  }
725
744
 
726
745
  /**
@@ -763,6 +782,7 @@ export default class ReleaseGroup extends EventEmitter {
763
782
  this.state = "stopped"
764
783
  this.stoppedAt = new Date().toISOString()
765
784
  this.releasePortReservations()
785
+ this.abandonLocalConnections()
766
786
  }
767
787
 
768
788
  /** @returns {Promise<void>} Quiesces every release process without waiting for its drain. */
@@ -1649,10 +1649,13 @@ test("pruned release connection completion closes the incumbent listener session
1649
1649
  expect(closeCount).toBe(1)
1650
1650
  expect(daemon.incumbentListenerControl).toBe(undefined)
1651
1651
 
1652
+ // A nonzero count for a release the daemon no longer retains is a stale drain timeout, not a
1653
+ // live connection: reconcile it to zero and close the session instead of throwing, so the
1654
+ // owner handoff cannot be deadlocked by a pruned generation's frozen count.
1652
1655
  daemon.incumbentListenerControl = controlSession
1653
- await expect(() => daemon.handleIncumbentListenerEvent({connections: {http: 1, websocket: 0}, event: "owner-connection-state", releaseId: "pruned"}, session)).toThrow(/unknown release pruned/)
1654
- expect(closeCount).toBe(1)
1655
- expect(daemon.incumbentListenerControl).toBe(session)
1656
+ expect(() => daemon.handleIncumbentListenerEvent({connections: {http: 1, websocket: 0}, event: "owner-connection-state", releaseId: "pruned"}, session)).not.toThrow()
1657
+ expect(closeCount).toBe(2)
1658
+ expect(daemon.incumbentListenerControl).toBe(undefined)
1656
1659
  })
1657
1660
 
1658
1661
  test("same-authority owner replacement preserves completed activation compensation without replaying hooks", async () => {
@@ -93,4 +93,61 @@ test("committed generation restoration does not start after shutdown begins", as
93
93
  await expect(release.restartCommittedGeneration()).rejects.toThrow(/shutting down/)
94
94
  expect(starts).toBe(0)
95
95
  })
96
+
97
+ test("stopping a release abandons its connection count instead of persisting a stale drain timeout", async () => {
98
+ const release = buildRelease({command: "run web", id: "web", policy: "proxied", port: {from: 0, to: 0}})
99
+ // In-flight connections whose response never completes (a drain timeout) hold the count.
100
+ const stuck = [0, 1, 2, 3].map(() => release.retainConnection("http"))
101
+
102
+ expect(release.connectionCount).toBe(4)
103
+
104
+ await release.stop()
105
+
106
+ expect(stuck).toHaveLength(4)
107
+ expect(release.state).toBe("stopped")
108
+ expect(release.status().connectionCount).toBe(0)
109
+ expect(release.localConnections()).toEqual({http: 0, websocket: 0})
110
+ })
111
+
112
+ test("restoring a stopped release does not re-adopt a stale connection count", async () => {
113
+ const release = buildRelease({command: "run web", id: "web", policy: "proxied", port: {from: 0, to: 0}})
114
+
115
+ const processStatus = /** @type {import("../src/managed-process.js").ManagedProcessStatus} */ ({
116
+ children: [],
117
+ command: "run web",
118
+ cwd: "/tmp/rel",
119
+ exitCode: 143,
120
+ exitSignal: "SIGTERM",
121
+ id: "web",
122
+ lastMemoryRestartAt: undefined,
123
+ lastStartReason: "deploy",
124
+ logs: [],
125
+ memoryRestarts: 0,
126
+ pid: undefined,
127
+ restarts: 0,
128
+ rssBytes: undefined,
129
+ startedAt: "2026-10-08T06:22:23.500Z",
130
+ state: "stopped",
131
+ uptimeMs: 900000
132
+ })
133
+
134
+ await release.restore({
135
+ activatedAt: "2026-10-08T06:22:23.583Z",
136
+ connectionCount: 14,
137
+ connections: {http: 14, websocket: 0},
138
+ drainStartedAt: "2026-10-08T07:58:00.464Z",
139
+ ports: {web: 4200},
140
+ processes: [processStatus],
141
+ releaseId: "v1",
142
+ releasePath: "/tmp/rel",
143
+ retirementError: undefined,
144
+ revision: "v1",
145
+ state: "stopped",
146
+ stoppedAt: "2026-10-08T08:28:02.005Z"
147
+ }, {synchronizeLifecycleRole: false})
148
+
149
+ expect(release.state).toBe("stopped")
150
+ expect(release.status().connectionCount).toBe(0)
151
+ expect(release.localConnections()).toEqual({http: 0, websocket: 0})
152
+ })
96
153
  })
@@ -0,0 +1,84 @@
1
+ // @ts-check
2
+
3
+ import {describe, expect, test} from "@velocious/testing"
4
+ import RollbridgeDaemon from "../src/daemon.js"
5
+ import ReleaseGroup from "../src/release-group.js"
6
+ import {normalizeConfig} from "../src/config.js"
7
+
8
+ describe("stale connection handoff", () => {
9
+
10
+ /**
11
+ * @param {import("../src/json.js").JsonValue} webProcess - The single proxied process definition.
12
+ * @returns {import("../src/config.js").RollbridgeConfig} A normalized daemon config.
13
+ */
14
+ function daemonConfig(webProcess) {
15
+ return normalizeConfig({
16
+ application: "stale-connection-handoff-test",
17
+ control: {path: "/tmp/rollbridge-stale-connection-handoff.sock"},
18
+ processes: [webProcess],
19
+ proxy: {host: "127.0.0.1", port: 0}
20
+ })
21
+ }
22
+
23
+ const webProcess = {
24
+ command: "run web",
25
+ id: "web",
26
+ policy: "proxied",
27
+ port: {from: 0, to: 0}
28
+ }
29
+
30
+ test("the serialized owner handoff never carries a stopped release's stale connection count", () => {
31
+ const daemon = new RollbridgeDaemon({config: daemonConfig(webProcess), logger: () => {}})
32
+ const release = new ReleaseGroup({
33
+ config: daemon.config,
34
+ logger: () => {},
35
+ releaseId: "stopped-v1",
36
+ releasePath: "/tmp/stopped-v1",
37
+ revision: "stopped-v1"
38
+ })
39
+
40
+ release.state = "stopped"
41
+ // Frozen drain-timeout count that a prior owner persisted (the 062147 incident shape).
42
+ release.setTransferredConnections({http: 14, websocket: 0})
43
+ daemon.releases.set(release.releaseId, release)
44
+ daemon.setListenerConnectionSource("incumbent-listener", release.releaseId, {http: 14, websocket: 0})
45
+
46
+ expect(daemon.serializedListenerConnectionSources()).toEqual({})
47
+ })
48
+
49
+ test("a successor daemon reconciles a stale report for an unretained release instead of failing the handoff", () => {
50
+ const logs = /** @type {{message: string, data?: Record<string, import("../src/json.js").JsonValue>}[]} */ ([])
51
+ const daemon = new RollbridgeDaemon({
52
+ config: daemonConfig(webProcess),
53
+ logger: (message, data) => {
54
+ logs.push({message, data})
55
+ }
56
+ })
57
+
58
+ // The candidate daemon does not rehydrate the stopped release; the incumbent's handoff
59
+ // still reports a nonzero count for it.
60
+ expect(() => daemon.setListenerConnectionSource("incumbent-listener", "062147", {http: 14, websocket: 0})).not.toThrow()
61
+
62
+ expect(daemon.serializedListenerConnectionSources()).toEqual({})
63
+ expect(logs.some((entry) => entry.message === "dropping stale connection state for unretained release")).toBe(true)
64
+ })
65
+
66
+ test("live release connection counts still cross the owner handoff", () => {
67
+ const daemon = new RollbridgeDaemon({config: daemonConfig(webProcess), logger: () => {}})
68
+ const release = new ReleaseGroup({
69
+ config: daemon.config,
70
+ logger: () => {},
71
+ releaseId: "active-v2",
72
+ releasePath: "/tmp/active-v2",
73
+ revision: "active-v2"
74
+ })
75
+
76
+ release.state = "active"
77
+ daemon.releases.set(release.releaseId, release)
78
+ daemon.setListenerConnectionSource("incumbent-listener", release.releaseId, {http: 3, websocket: 1})
79
+
80
+ // The incumbent's live counts must still cross the handoff so the successor can adopt them.
81
+ expect(daemon.serializedListenerConnectionSources()).toEqual({"incumbent-listener": {"active-v2": {http: 3, websocket: 1}}})
82
+ expect(release.status().connections).toEqual({http: 3, websocket: 1})
83
+ })
84
+ })