rollbridge 0.1.39 → 0.1.40

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)
@@ -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
+ }