rollbridge 0.1.54 → 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.
Files changed (36) hide show
  1. package/AGENTS.md +5 -0
  2. package/changelog.d/20260909120000-velocious-testing.md +1 -0
  3. package/changelog.d/20261008-stale-stopped-connection-count.md +14 -0
  4. package/eslint.config.js +8 -0
  5. package/package.json +3 -2
  6. package/src/daemon.js +30 -2
  7. package/src/release-group.js +21 -1
  8. package/test/completion.test.js +18 -16
  9. package/test/config-examples.test.js +16 -17
  10. package/test/config-path.test.js +10 -11
  11. package/test/config-validation.test.js +163 -167
  12. package/test/control-protocol.test.js +22 -21
  13. package/test/daemon-bootstrap.test.js +104 -104
  14. package/test/daemon-runtime.test.js +17 -26
  15. package/test/doctor.test.js +51 -49
  16. package/test/event-log.test.js +13 -11
  17. package/test/guardian-client.test.js +140 -148
  18. package/test/health.test.js +6 -4
  19. package/test/logs.test.js +17 -18
  20. package/test/managed-process.test.js +96 -91
  21. package/test/owner-recovery.test.js +226 -239
  22. package/test/owner-replacement.test.js +230 -222
  23. package/test/package-metadata.test.js +48 -39
  24. package/test/port-allocator.test.js +13 -16
  25. package/test/predeploy-cleanup.test.js +12 -10
  26. package/test/process-memory.test.js +17 -15
  27. package/test/proxy.test.js +10 -8
  28. package/test/recover.test.js +30 -23
  29. package/test/release-group.test.js +73 -17
  30. package/test/release-retention.test.js +10 -8
  31. package/test/release-runtime-retention.test.js +31 -39
  32. package/test/rollbridge.test.js +377 -396
  33. package/test/shutdown-completion.test.js +51 -51
  34. package/test/stale-connection-handoff.test.js +84 -0
  35. package/test/state-store.test.js +10 -8
  36. package/test/system-ids.test.js +15 -13
package/AGENTS.md CHANGED
@@ -61,6 +61,11 @@ external-supervisor migration path, not that atomic handoff.
61
61
 
62
62
  ## Validation and publication
63
63
 
64
+ All tests use `@velocious/testing` declarations, hooks, and `expect` matchers.
65
+ Do not import `node:test` or `node:assert`, or add assertion compatibility wrappers.
66
+ Run every changed test file locally with `npx --no-install velocious-test test/<name>.test.js`
67
+ and require it to pass before committing or pushing; lint or CI is not a substitute.
68
+
64
69
  The project is ESM JavaScript with JSDoc type checking. Package scripts are the
65
70
  source of truth: `npm run typecheck`, `npm run lint`, `npm test`, and the combined
66
71
  `npm run all-checks`. Run focused checks for the files changed; documentation-only
@@ -0,0 +1 @@
1
+ - Migrated all Rollbridge tests to `@velocious/testing` declarations, lifecycle APIs, and native `expect` matchers. Lint now rejects Node test and assertion imports in tests.
@@ -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/eslint.config.js CHANGED
@@ -81,6 +81,14 @@ export default defineConfig([
81
81
  "no-unused-vars": ["error", {argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_"}]
82
82
  }
83
83
  },
84
+ {
85
+ files: ["test/**/*.js"],
86
+ rules: {
87
+ "no-restricted-imports": ["error", {
88
+ patterns: [{group: ["node:test", "node:test/*", "test", "node:assert", "node:assert/*", "assert", "assert/*"], message: "Use @velocious/testing declarations and expect matchers."}]
89
+ }]
90
+ }
91
+ },
84
92
  jsdoc({
85
93
  config: "flat/recommended",
86
94
  files: ["**/*.{js,mjs,cjs}", "**/bin/rollbridge"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.54",
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",
@@ -29,7 +29,7 @@
29
29
  "all-checks": "npm run typecheck && npm run lint && npm test",
30
30
  "lint": "npm run eslint && npm run typecheck",
31
31
  "release:patch": "release-patch",
32
- "test": "node --test test/*.test.js",
32
+ "test": "velocious-test test/*.test.js",
33
33
  "typecheck": "tsc --noEmit",
34
34
  "eslint": "eslint"
35
35
  },
@@ -44,6 +44,7 @@
44
44
  "@eslint/js": "^10.0.1",
45
45
  "@types/http-proxy": "^1.17.17",
46
46
  "@types/node": "^25.9.1",
47
+ "@velocious/testing": "^0.0.12",
47
48
  "eslint": "^10.4.0",
48
49
  "eslint-plugin-jsdoc": "^62.9.0",
49
50
  "globals": "^17.6.0",
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. */
@@ -1,9 +1,10 @@
1
1
  // @ts-check
2
2
 
3
- import assert from "node:assert/strict"
4
- import test from "node:test"
3
+ import {describe, expect, test} from "@velocious/testing"
5
4
  import {runCli} from "../src/cli.js"
6
5
 
6
+ describe("completion", () => {
7
+
7
8
  /**
8
9
  * Runs the CLI while capturing stdout, stderr, and the resulting exit code.
9
10
  * @param {string[]} argv - Process argv.
@@ -39,29 +40,30 @@ async function capture(argv) {
39
40
  test("completion bash prints a sourceable script with commands and option flags", async () => {
40
41
  const {code, output} = await capture(["node", "rollbridge", "completion", "bash"])
41
42
 
42
- assert.notEqual(code, 1)
43
- assert.match(output, /complete -F _rollbridge rollbridge/)
44
- assert.match(output, /compgen -W "daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
43
+ expect(code).not.toBe(1)
44
+ expect(output).toMatch(/complete -F _rollbridge rollbridge/)
45
+ expect(output).toMatch(/compgen -W "daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
45
46
  // A command's own options are completed after the command.
46
- assert.match(output, /deploy\)\n\s+opts="[^"]*--release-path[^"]*"/)
47
- assert.match(output, /recover-generation-transition\)\n\s+opts="--config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent"/)
48
- assert.match(output, /ensure-daemon\)\n\s+opts="[^"]*--daemon-runtime-path[^"]*"/)
49
- assert.match(output, /restart\)\n\s+opts="[^"]*--policy[^"]*"/)
47
+ expect(output).toMatch(/deploy\)\n\s+opts="[^"]*--release-path[^"]*"/)
48
+ expect(output).toMatch(/recover-generation-transition\)\n\s+opts="--config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent"/)
49
+ expect(output).toMatch(/ensure-daemon\)\n\s+opts="[^"]*--daemon-runtime-path[^"]*"/)
50
+ expect(output).toMatch(/restart\)\n\s+opts="[^"]*--policy[^"]*"/)
50
51
  })
51
52
 
52
53
  test("completion zsh prints a #compdef script with per-command options", async () => {
53
54
  const {output} = await capture(["node", "rollbridge", "completion", "zsh"])
54
55
 
55
- assert.match(output, /^#compdef rollbridge/)
56
- assert.match(output, /compdef _rollbridge rollbridge/)
57
- assert.match(output, /commands=\(daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion\)/)
58
- assert.match(output, /recover-generation-transition\) compadd -- --config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent/)
59
- assert.match(output, /events\) compadd -- [^\n]*--limit/)
56
+ expect(output).toMatch(/^#compdef rollbridge/)
57
+ expect(output).toMatch(/compdef _rollbridge rollbridge/)
58
+ expect(output).toMatch(/commands=\(daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion\)/)
59
+ expect(output).toMatch(/recover-generation-transition\) compadd -- --config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent/)
60
+ expect(output).toMatch(/events\) compadd -- [^\n]*--limit/)
60
61
  })
61
62
 
62
63
  test("completion rejects an unsupported shell with a non-zero exit code", async () => {
63
64
  const {code, errorOutput} = await capture(["node", "rollbridge", "completion", "fish"])
64
65
 
65
- assert.equal(code, 1)
66
- assert.match(errorOutput, /Unsupported shell "fish"\. Supported shells: bash, zsh\./)
66
+ expect(code).toBe(1)
67
+ expect(errorOutput).toMatch(/Unsupported shell "fish"\. Supported shells: bash, zsh\./)
68
+ })
67
69
  })
@@ -1,34 +1,32 @@
1
1
  // @ts-check
2
2
 
3
- import assert from "node:assert/strict"
4
3
  import fs from "node:fs/promises"
5
4
  import os from "node:os"
6
5
  import path from "node:path"
7
- import test from "node:test"
6
+ import {describe, expect, test} from "@velocious/testing"
8
7
  import {fileURLToPath} from "node:url"
9
8
  import {loadConfig} from "../src/config.js"
10
9
 
10
+ describe("config-examples", () => {
11
+
11
12
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
12
13
 
13
14
  test("TensorBuzz example config loads", async () => {
14
15
  const config = await loadConfig(path.join(repoRoot, "examples", "tensorbuzz.com.js"))
15
16
 
16
- assert.equal(config.application, "tensorbuzz")
17
- assert.equal(config.control.path, "/tmp/rollbridge-tensorbuzz.sock")
18
- assert.equal(config.proxy.host, "127.0.0.1")
19
- assert.equal(config.proxy.port, 4500)
20
- assert.equal(config.proxy.healthPath, "/ping")
21
- assert.deepEqual(
22
- config.processes.map((processConfig) => [processConfig.id, processConfig.policy]),
23
- [
17
+ expect(config.application).toBe("tensorbuzz")
18
+ expect(config.control.path).toBe("/tmp/rollbridge-tensorbuzz.sock")
19
+ expect(config.proxy.host).toBe("127.0.0.1")
20
+ expect(config.proxy.port).toBe(4500)
21
+ expect(config.proxy.healthPath).toBe("/ping")
22
+ expect(config.processes.map((processConfig) => [processConfig.id, processConfig.policy])).toEqual([
24
23
  ["beacon", "service"],
25
24
  ["background-jobs-main", "service"],
26
25
  ["background-jobs-worker", "companion"],
27
26
  ["web", "proxied"]
28
- ]
29
- )
30
- assert.equal(config.processes[2].lifecycle.reactivateCommand, "appctl jobs-worker-reactivate --pid $ROLLBRIDGE_PID")
31
- assert.equal(config.processes[3].env.VELOCIOUS_BACKGROUND_JOBS_PORT, "{{ports.background-jobs-main}}")
27
+ ])
28
+ expect(config.processes[2].lifecycle.reactivateCommand).toBe("appctl jobs-worker-reactivate --pid $ROLLBRIDGE_PID")
29
+ expect(config.processes[3].env.VELOCIOUS_BACKGROUND_JOBS_PORT).toBe("{{ports.background-jobs-main}}")
32
30
  })
33
31
 
34
32
  test("loadConfig resolves a config module that exports a function", async () => {
@@ -50,11 +48,12 @@ test("loadConfig resolves a config module that exports a function", async () =>
50
48
  try {
51
49
  const config = await loadConfig(configPath)
52
50
 
53
- assert.equal(config.application, "computed-app")
54
- assert.equal(config.proxy.port, 8190)
55
- assert.equal(config.processes[0].id, "web")
51
+ expect(config.application).toBe("computed-app")
52
+ expect(config.proxy.port).toBe(8190)
53
+ expect(config.processes[0].id).toBe("web")
56
54
  } finally {
57
55
  delete process.env.ROLLBRIDGE_TEST_APP
58
56
  await fs.rm(dir, {force: true, recursive: true})
59
57
  }
60
58
  })
59
+ })
@@ -1,13 +1,14 @@
1
1
  // @ts-check
2
2
 
3
- import assert from "node:assert/strict"
4
3
  import fs from "node:fs/promises"
5
4
  import os from "node:os"
6
5
  import path from "node:path"
7
- import test from "node:test"
6
+ import {describe, expect, test} from "@velocious/testing"
8
7
  import {loadConfig, resolveConfigPath} from "../src/config.js"
9
8
  import {runCli} from "../src/cli.js"
10
9
 
10
+ describe("config-path", () => {
11
+
11
12
  const validConfig = {
12
13
  application: "demo",
13
14
  control: {path: "/tmp/rollbridge-config-path.sock"},
@@ -34,7 +35,7 @@ test("resolveConfigPath returns an explicit path unchanged", async () => {
34
35
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
35
36
 
36
37
  try {
37
- assert.equal(await resolveConfigPath("/somewhere/custom.js", dir), "/somewhere/custom.js")
38
+ expect(await resolveConfigPath("/somewhere/custom.js", dir)).toBe("/somewhere/custom.js")
38
39
  } finally {
39
40
  await fs.rm(dir, {force: true, recursive: true})
40
41
  }
@@ -46,7 +47,7 @@ test("resolveConfigPath resolves rollbridge.js in the working directory", async
46
47
  try {
47
48
  await writeConfigModule(dir)
48
49
 
49
- assert.equal(await resolveConfigPath(undefined, dir), path.join(dir, "rollbridge.js"))
50
+ expect(await resolveConfigPath(undefined, dir)).toBe(path.join(dir, "rollbridge.js"))
50
51
  } finally {
51
52
  await fs.rm(dir, {force: true, recursive: true})
52
53
  }
@@ -56,10 +57,7 @@ test("resolveConfigPath throws an actionable error when no default config exists
56
57
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
57
58
 
58
59
  try {
59
- await assert.rejects(
60
- () => resolveConfigPath(undefined, dir),
61
- /No config file found.*rollbridge\.js/
62
- )
60
+ await expect(resolveConfigPath(undefined, dir)).rejects.toThrow(/No config file found.*rollbridge\.js/)
63
61
  } finally {
64
62
  await fs.rm(dir, {force: true, recursive: true})
65
63
  }
@@ -70,11 +68,11 @@ test("loadConfig reloads a changed CommonJS module", async () => {
70
68
  const configPath = await writeConfigModule(dir)
71
69
 
72
70
  try {
73
- assert.equal((await loadConfig(configPath)).application, "demo")
71
+ expect((await loadConfig(configPath)).application).toBe("demo")
74
72
 
75
73
  await fs.writeFile(configPath, `module.exports = ${JSON.stringify({...validConfig, application: "updated"}, null, 2)}\n`)
76
74
 
77
- assert.equal((await loadConfig(configPath)).application, "updated")
75
+ expect((await loadConfig(configPath)).application).toBe("updated")
78
76
  } finally {
79
77
  await fs.rm(dir, {force: true, recursive: true})
80
78
  }
@@ -101,5 +99,6 @@ test("validate CLI command resolves the default config when --config is omitted"
101
99
  await fs.rm(dir, {force: true, recursive: true})
102
100
  }
103
101
 
104
- assert.match(lines.join("\n"), /rollbridge\.js is valid: 1 process, proxy on 127\.0\.0\.1:8182\./)
102
+ expect(lines.join("\n")).toMatch(/rollbridge\.js is valid: 1 process, proxy on 127\.0\.0\.1:8182\./)
103
+ })
105
104
  })