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.
- package/AGENTS.md +5 -0
- package/changelog.d/20260909120000-velocious-testing.md +1 -0
- package/changelog.d/20261008-stale-stopped-connection-count.md +14 -0
- package/eslint.config.js +8 -0
- package/package.json +3 -2
- package/src/daemon.js +30 -2
- package/src/release-group.js +21 -1
- package/test/completion.test.js +18 -16
- package/test/config-examples.test.js +16 -17
- package/test/config-path.test.js +10 -11
- package/test/config-validation.test.js +163 -167
- package/test/control-protocol.test.js +22 -21
- package/test/daemon-bootstrap.test.js +104 -104
- package/test/daemon-runtime.test.js +17 -26
- package/test/doctor.test.js +51 -49
- package/test/event-log.test.js +13 -11
- package/test/guardian-client.test.js +140 -148
- package/test/health.test.js +6 -4
- package/test/logs.test.js +17 -18
- package/test/managed-process.test.js +96 -91
- package/test/owner-recovery.test.js +226 -239
- package/test/owner-replacement.test.js +230 -222
- package/test/package-metadata.test.js +48 -39
- package/test/port-allocator.test.js +13 -16
- package/test/predeploy-cleanup.test.js +12 -10
- package/test/process-memory.test.js +17 -15
- package/test/proxy.test.js +10 -8
- package/test/recover.test.js +30 -23
- package/test/release-group.test.js +73 -17
- package/test/release-retention.test.js +10 -8
- package/test/release-runtime-retention.test.js +31 -39
- package/test/rollbridge.test.js +377 -396
- package/test/shutdown-completion.test.js +51 -51
- package/test/stale-connection-handoff.test.js +84 -0
- package/test/state-store.test.js +10 -8
- 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.
|
|
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": "
|
|
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
|
|
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(
|
|
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)
|
package/src/release-group.js
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/test/completion.test.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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 "
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
})
|
package/test/config-path.test.js
CHANGED
|
@@ -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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
+
expect(lines.join("\n")).toMatch(/rollbridge\.js is valid: 1 process, proxy on 127\.0\.0\.1:8182\./)
|
|
103
|
+
})
|
|
105
104
|
})
|