rollbridge 0.1.12 → 0.1.14

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/README.md CHANGED
@@ -399,6 +399,23 @@ Start the daemon:
399
399
  rollbridge daemon --config rollbridge.js
400
400
  ```
401
401
 
402
+ Start the daemon and bootstrap an exact prepared release before leaving it in
403
+ the foreground (for example from a boot-time service manager):
404
+
405
+ ```bash
406
+ rollbridge daemon --config /srv/ticket-server/rollbridge.js \
407
+ --release-path /srv/ticket-server/releases/20260813090000/ticket-server \
408
+ --release-id 20260813090000 --revision abc123
409
+ ```
410
+
411
+ The four bootstrap inputs are all-or-nothing and use absolute config/release
412
+ paths. Rollbridge binds its proxy, activates the release through the normal
413
+ deploy path, then exposes the control socket and stays foreground. A failed
414
+ activation stops only processes started by that attempt and exits non-zero;
415
+ persisted processes from a previous daemon are reported as orphans and are never
416
+ recovered or killed implicitly; their live PID records remain in `statePath` for
417
+ explicit recovery.
418
+
402
419
  Start the daemon only when it is not already running:
403
420
 
404
421
  ```bash
package/docs/cli.md CHANGED
@@ -24,6 +24,7 @@ process-policy details.
24
24
 
25
25
  ```
26
26
  rollbridge daemon [--config <path>]
27
+ [--release-path <path> --release-id <id> --revision <sha>]
27
28
  ```
28
29
 
29
30
  Runs the supervisor in the foreground: binds the stable proxy port and the
@@ -32,6 +33,29 @@ processes, closes the servers, removes the control socket, and exits `0`.
32
33
  Structured JSON log lines are written to stdout. Run it under a process manager
33
34
  such as systemd (see `examples/rollbridge.service`).
34
35
 
36
+ For boot/crash recovery, pass an explicit absolute `--config` and all three
37
+ release options together. Rollbridge validates every bootstrap input before it
38
+ binds listeners or starts processes, then binds the proxy and activates that
39
+ exact release through the normal deploy path: services,
40
+ companions, the proxied process and health check, traffic switching, singletons,
41
+ and service-template refresh. Only after activation succeeds does it expose the
42
+ control socket, preventing another deployment from overlapping bootstrap. It
43
+ then remains in the foreground with the normal signal behavior.
44
+
45
+ Bootstrap paths must be absolute and normalized, the release path must be an
46
+ accessible directory, and release id/revision values accept letters, numbers,
47
+ dots, underscores, and hyphens (maximum 200 characters, beginning with a letter
48
+ or number). Supplying only some bootstrap options, or an invalid value, exits
49
+ non-zero before listeners start. Activation failure emits a structured
50
+ `bootstrap activation failed` event, cleans up processes owned by that attempt,
51
+ and exits non-zero without exposing the control socket or inventing an active
52
+ release. `statePath` entries from a previous daemon remain advisory orphans:
53
+ bootstrap never runs recovery and never signals those processes, and retains
54
+ their live PID records in `statePath` for explicit recovery.
55
+
56
+ With no release options, daemon behavior is unchanged: it starts listener-only
57
+ and waits for control-socket deployments.
58
+
35
59
  ## `ensure-daemon`
36
60
 
37
61
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/cli.js CHANGED
@@ -30,12 +30,16 @@ export async function runCli(argv) {
30
30
  program
31
31
  .command("daemon")
32
32
  .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
33
+ .option("--release-path <path>", "Bootstrap release path (requires --config, --release-id, and --revision)")
34
+ .option("--release-id <id>", "Bootstrap release id (requires --config, --release-path, and --revision)")
35
+ .option("--revision <sha>", "Bootstrap revision (requires --config, --release-path, and --release-id)")
33
36
  .action(async (options) => {
37
+ const bootstrap = await validateDaemonBootstrapOptions(options)
34
38
  const configPath = await resolveConfigPath(options.config)
35
39
  const config = await loadConfig(configPath)
36
40
  const daemon = new RollbridgeDaemon({config, configPath})
37
41
 
38
- await daemon.start()
42
+ await daemon.start({exposeControl: !bootstrap})
39
43
 
40
44
  const shutdown = async () => {
41
45
  await daemon.shutdown()
@@ -44,6 +48,18 @@ export async function runCli(argv) {
44
48
 
45
49
  process.once("SIGINT", () => { void shutdown() })
46
50
  process.once("SIGTERM", () => { void shutdown() })
51
+
52
+ if (bootstrap) {
53
+ try {
54
+ await daemon.deploy(bootstrap)
55
+ await daemon.exposeControl()
56
+ } catch {
57
+ daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error"})
58
+ await daemon.shutdown()
59
+ process.exitCode = 1
60
+ return
61
+ }
62
+ }
47
63
  })
48
64
 
49
65
  program
@@ -696,6 +712,49 @@ async function validateConfigFile(configPath) {
696
712
  }
697
713
  }
698
714
 
715
+ /**
716
+ * Validates the daemon's optional all-or-nothing bootstrap release interface before
717
+ * config loading or listener startup.
718
+ * @param {{config?: string, releaseId?: string, releasePath?: string, revision?: string}} options - Daemon CLI options.
719
+ * @returns {Promise<{releaseId: string, releasePath: string, revision: string} | undefined>} Validated bootstrap metadata.
720
+ */
721
+ async function validateDaemonBootstrapOptions(options) {
722
+ const bootstrapValues = [options.releasePath, options.releaseId, options.revision]
723
+ const bootstrapRequested = bootstrapValues.some((value) => value !== undefined)
724
+
725
+ if (!bootstrapRequested) return undefined
726
+
727
+ if (!options.config || bootstrapValues.some((value) => value === undefined)) {
728
+ throw new Error("Daemon bootstrap options --config, --release-path, --release-id, and --revision must be provided together.")
729
+ }
730
+
731
+ if (!path.isAbsolute(options.config)) throw new Error("Daemon bootstrap --config must be an absolute path.")
732
+ if (path.normalize(options.config) !== options.config) throw new Error("Daemon bootstrap --config must be normalized and must not contain unsafe traversal segments.")
733
+ if (!path.isAbsolute(/** @type {string} */ (options.releasePath))) throw new Error("Daemon bootstrap --release-path must be an absolute path.")
734
+ if (path.normalize(/** @type {string} */ (options.releasePath)) !== options.releasePath) throw new Error("Daemon bootstrap --release-path must be normalized and must not contain unsafe traversal segments.")
735
+
736
+ const releaseId = /** @type {string} */ (options.releaseId)
737
+ const revision = /** @type {string} */ (options.revision)
738
+ const safeIdentifier = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/
739
+
740
+ if (!safeIdentifier.test(releaseId)) throw new Error("Daemon bootstrap --release-id must be a non-empty safe identifier containing only letters, numbers, dots, underscores, and hyphens.")
741
+ if (!safeIdentifier.test(revision)) throw new Error("Daemon bootstrap --revision must be a non-empty safe identifier containing only letters, numbers, dots, underscores, and hyphens.")
742
+
743
+ let releaseStat
744
+
745
+ try {
746
+ releaseStat = await fsPromises.stat(/** @type {string} */ (options.releasePath))
747
+ } catch (error) {
748
+ const reason = error instanceof Error ? error.message : String(error)
749
+
750
+ throw new Error(`Daemon bootstrap --release-path is not accessible: ${reason}`, {cause: error})
751
+ }
752
+
753
+ if (!releaseStat.isDirectory()) throw new Error("Daemon bootstrap --release-path must name a directory.")
754
+
755
+ return {releaseId, releasePath: /** @type {string} */ (options.releasePath), revision}
756
+ }
757
+
699
758
  /**
700
759
  * Starts a daemon when needed and waits until it accepts status commands.
701
760
  * @param {object} args - Options.
package/src/daemon.js CHANGED
@@ -56,6 +56,7 @@ export default class RollbridgeDaemon {
56
56
  this.statePath = config.statePath
57
57
  this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
58
58
  this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
59
+ this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
59
60
  // Still-alive managed processes left by a previous daemon (from statePath), captured at
60
61
  // startup and surfaced in status(). The daemon cannot re-manage them, only report them.
61
62
  this.orphans = /** @type {{id: string, pid: number, releaseId: string | null}[]} */ ([])
@@ -63,11 +64,29 @@ export default class RollbridgeDaemon {
63
64
  this.proxy.on("error", (error, req, res) => this.onProxyError(error, req, res))
64
65
  }
65
66
 
66
- /** @returns {Promise<void>} Starts proxy and control listeners. */
67
- async start() {
67
+ /**
68
+ * Starts daemon listeners.
69
+ * @param {{exposeControl?: boolean}} [options] - Whether to expose the control socket immediately.
70
+ * @returns {Promise<void>} Resolves when the requested listeners are ready.
71
+ */
72
+ async start({exposeControl = true} = {}) {
68
73
  await this.reportOrphans()
69
74
  await this.startProxy()
75
+ if (exposeControl) await this.exposeControl()
76
+ }
77
+
78
+ /** @returns {Promise<void>} Exposes control commands and begins periodic state persistence. */
79
+ async exposeControl() {
80
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
81
+
70
82
  await this.startControlServer()
83
+
84
+ if (this.stopping) {
85
+ await this.closeServer(this.controlServer)
86
+ await fs.rm(this.config.control.path, {force: true})
87
+ throw new Error("Rollbridge is shutting down")
88
+ }
89
+
71
90
  this.startStatePersistence()
72
91
  }
73
92
 
@@ -334,19 +353,26 @@ export default class RollbridgeDaemon {
334
353
  releaseId: newReleaseId,
335
354
  releasePath,
336
355
  revision,
337
- servicePorts: this.servicePorts
356
+ servicePorts: this.servicePorts,
357
+ shouldStart: () => !this.stopping
338
358
  })
339
359
 
340
360
  this.logger("deploy starting", {releaseId: newReleaseId, releasePath, revision})
341
361
  const startedServices = /** @type {string[]} */ ([])
342
362
 
363
+ this.startingReleases.add(release)
364
+
343
365
  try {
344
366
  await this.ensureServices(release, startedServices)
345
367
  await release.start()
368
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
346
369
  } catch (error) {
347
370
  this.logger("deploy failed", {error: error instanceof Error ? error.message : String(error), releaseId: newReleaseId})
371
+ await release.stop()
348
372
  await this.stopStartedServices(startedServices)
349
373
  throw error
374
+ } finally {
375
+ this.startingReleases.delete(release)
350
376
  }
351
377
 
352
378
  const previousRelease = this.activeRelease
@@ -467,6 +493,7 @@ export default class RollbridgeDaemon {
467
493
  await release.allocatePorts()
468
494
 
469
495
  for (const processConfig of release.config.processes) {
496
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
470
497
  if (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff") continue
471
498
  if (this.services.has(processConfig.id)) continue
472
499
 
@@ -486,6 +513,8 @@ export default class RollbridgeDaemon {
486
513
  delete this.servicePorts[processConfig.id]
487
514
  throw error
488
515
  }
516
+
517
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
489
518
  }
490
519
  }
491
520
 
@@ -532,12 +561,14 @@ export default class RollbridgeDaemon {
532
561
  */
533
562
  async replaceSingletons(release) {
534
563
  for (const processConfig of this.config.processes) {
564
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
535
565
  if (processConfig.policy !== "singleton") continue
536
566
 
537
567
  const previous = this.singletons.get(processConfig.id)
538
568
 
539
569
  if (previous) {
540
570
  await previous.stop()
571
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
541
572
  }
542
573
 
543
574
  const singleton = release.buildProcess(processConfig)
@@ -688,9 +719,7 @@ export default class RollbridgeDaemon {
688
719
  if (!this.statePath || this.stopping) return
689
720
 
690
721
  const statePath = this.statePath
691
- // Drop the orphans view from the snapshot: it reflects a *previous* daemon's leftovers, not
692
- // this daemon's own managed state, and is recomputed from the persisted processes on restart.
693
- const {orphans: _orphans, ...status} = this.status()
722
+ const status = this.status()
694
723
  const snapshot = {...status, events: this.eventLog.recent(), persistedAt: new Date().toISOString()}
695
724
 
696
725
  // Serialize writes (and track the tail) so shutdown can wait for an in-flight write before
@@ -742,17 +771,24 @@ export default class RollbridgeDaemon {
742
771
  this.proxy.close()
743
772
  await Promise.allSettled([...this.services.values()].map((processInstance) => processInstance.stop()))
744
773
  await Promise.allSettled([...this.singletons.values()].map((processInstance) => processInstance.stop()))
774
+ await Promise.allSettled([...this.startingReleases].map((release) => release.stop()))
745
775
  await Promise.allSettled([...this.releases.values()].map((release) => release.stop()))
746
776
  await this.closeServer(this.proxyServer)
747
777
  await this.closeServer(this.controlServer)
748
778
  await fs.rm(this.config.control.path, {force: true})
749
779
 
750
- // A clean shutdown leaves no orphans, so remove the state file rather than leaving stale
751
- // pids. Wait for any in-flight write first so it can't recreate the file afterward (no new
752
- // writes start: stopping is set and the persist timer is cleared above).
780
+ // Wait for any in-flight write first so it can't recreate or overwrite the final state (no
781
+ // new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
782
+ // orphans are not owned by this daemon, so retain their records until they are confirmed gone.
753
783
  if (this.statePath) {
754
784
  if (this.pendingWrite) await this.pendingWrite
755
- await clearState(this.statePath)
785
+ const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
786
+
787
+ if (orphans.length > 0) {
788
+ await writeState(this.statePath, {activeReleaseId: null, orphans, releases: [], services: [], singletons: []})
789
+ } else {
790
+ await clearState(this.statePath)
791
+ }
756
792
  }
757
793
  }
758
794
 
@@ -40,8 +40,9 @@ export default class ReleaseGroup extends EventEmitter {
40
40
  * @param {string} args.releasePath - Release path.
41
41
  * @param {string | undefined} args.revision - Revision.
42
42
  * @param {Record<string, number>} [args.servicePorts] - Ports already owned by daemon-wide services.
43
+ * @param {() => boolean} [args.shouldStart] - Whether bootstrap may create another process.
43
44
  */
44
- constructor({config, logger, releaseId, releasePath, revision, servicePorts = {}}) {
45
+ constructor({config, logger, releaseId, releasePath, revision, servicePorts = {}, shouldStart = () => true}) {
45
46
  super()
46
47
 
47
48
  this.config = config
@@ -57,6 +58,7 @@ export default class ReleaseGroup extends EventEmitter {
57
58
  this.nonBlockingDrainIds = /** @type {Set<string>} */ (new Set())
58
59
  this.ports = /** @type {Record<string, number>} */ ({})
59
60
  this.servicePorts = servicePorts
61
+ this.shouldStart = shouldStart
60
62
  this.portsAllocated = false
61
63
  this.drainStartedAt = /** @type {string | undefined} */ (undefined)
62
64
  this.activatedAt = /** @type {string | undefined} */ (undefined)
@@ -72,6 +74,8 @@ export default class ReleaseGroup extends EventEmitter {
72
74
 
73
75
  for (const processConfig of this.releaseProcessStartOrder()) {
74
76
  for (let index = 0; index < processConfig.replicas; index += 1) {
77
+ if (!this.shouldStart()) throw new Error("Rollbridge is shutting down")
78
+
75
79
  const instanceId = replicaInstanceId(processConfig, index)
76
80
  const processInstance = this.buildProcess(processConfig, {count: processConfig.replicas, index, instanceId})
77
81
 
@@ -87,6 +91,8 @@ export default class ReleaseGroup extends EventEmitter {
87
91
  host: this.config.proxy.upstreamHost,
88
92
  port: this.ports[processConfig.id]
89
93
  })
94
+
95
+ if (!this.shouldStart()) throw new Error("Rollbridge is shutting down")
90
96
  }
91
97
  }
92
98
  } catch (error) {
@@ -86,6 +86,14 @@ export function liveProcesses(state, alive = isProcessAlive) {
86
86
  try {
87
87
  const snapshot = /** @type {import("./daemon.js").DaemonStatus} */ (state)
88
88
 
89
+ if (Array.isArray(snapshot.orphans)) {
90
+ for (const orphan of snapshot.orphans) {
91
+ if (typeof orphan.id === "string" && typeof orphan.pid === "number" && (typeof orphan.releaseId === "string" || orphan.releaseId === null) && alive(orphan.pid)) {
92
+ live.push({id: orphan.id, pid: orphan.pid, releaseId: orphan.releaseId})
93
+ }
94
+ }
95
+ }
96
+
89
97
  for (const release of snapshot.releases) {
90
98
  for (const process of release.processes) {
91
99
  if (typeof process.pid === "number" && alive(process.pid)) live.push({id: process.id, pid: process.pid, releaseId: release.releaseId})
@@ -0,0 +1,396 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import {spawn} from "node:child_process"
5
+ import {once} from "node:events"
6
+ import fs from "node:fs/promises"
7
+ import os from "node:os"
8
+ import path from "node:path"
9
+ import test from "node:test"
10
+ import {fileURLToPath} from "node:url"
11
+ import {sendControlCommand} from "../src/control-client.js"
12
+ import {isProcessAlive, liveProcesses, readState, writeState} from "../src/state-store.js"
13
+
14
+ const currentDir = path.dirname(fileURLToPath(import.meta.url))
15
+ const binPath = path.join(currentDir, "..", "bin", "rollbridge")
16
+ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
17
+
18
+ test("daemon bootstrap requires complete, safe, absolute inputs before binding listeners", async (t) => {
19
+ const cases = [
20
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1"], message: /must be provided together/},
21
+ {args: ["--config", "relative/config.js", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123"], message: /--config must be an absolute path/},
22
+ {args: ["--config", "CONFIG", "--release-path", "relative/release", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be an absolute path/},
23
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "unsafe id", "--revision", "abc123"], message: /--release-id/},
24
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "unsafe revision"], message: /--revision/}
25
+ ]
26
+
27
+ for (const testCase of cases) {
28
+ await t.test(testCase.message.source, async () => {
29
+ const fixture = await createFixture()
30
+ const args = testCase.args.map((arg) => arg === "CONFIG" ? fixture.configPath : arg === "RELEASE" ? fixture.root : arg)
31
+
32
+ try {
33
+ const result = await runDaemon(args)
34
+
35
+ assert.notEqual(result.code, 0)
36
+ assert.match(result.stderr, testCase.message)
37
+ await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
38
+ } finally {
39
+ await fs.rm(fixture.root, {force: true, recursive: true})
40
+ }
41
+ })
42
+ }
43
+ })
44
+
45
+ test("daemon bootstrap activates the exact release through the foreground daemon", async () => {
46
+ const fixture = await createFixture()
47
+ const child = spawnDaemon(fixture, {releaseId: "release-42", revision: "abc123"})
48
+
49
+ try {
50
+ await waitForLog(child, "control socket listening")
51
+ const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
52
+ const activeRelease = assertRelease(status, "release-42")
53
+
54
+ assert.equal(activeRelease.releasePath, fixture.root)
55
+ assert.equal(activeRelease.revision, "abc123")
56
+ assert.ok(status.proxy && typeof status.proxy === "object" && !Array.isArray(status.proxy) && typeof status.proxy.port === "number")
57
+ assert.equal((await fetch(`http://127.0.0.1:${status.proxy.port}/release`).then((response) => response.text())).trim(), "release-42")
58
+
59
+ child.kill("SIGTERM")
60
+ assert.equal((await once(child, "exit"))[0], 0)
61
+ } finally {
62
+ if (child.exitCode === null) child.kill("SIGKILL")
63
+ await fs.rm(fixture.root, {force: true, recursive: true})
64
+ }
65
+ })
66
+
67
+ test("daemon bootstrap does not expose control deploys until activation completes", async () => {
68
+ const fixture = await createFixture({healthGate: true, healthTimeoutMs: 60000})
69
+ const started = waitForFile(fixture.startedPath)
70
+ const child = spawnDaemon(fixture, {releaseId: "bootstrap-release", revision: "bootstrap123"})
71
+
72
+ try {
73
+ await started
74
+ await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
75
+
76
+ await fs.writeFile(fixture.healthGatePath, "ready\n")
77
+ await waitForLog(child, "control socket listening")
78
+
79
+ const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
80
+
81
+ assert.equal(status.activeReleaseId, "bootstrap-release")
82
+ assert.ok(Array.isArray(status.releases))
83
+ assert.equal(status.releases.length, 1)
84
+ assertRelease(status, "bootstrap-release")
85
+
86
+ child.kill("SIGTERM")
87
+ assert.equal((await once(child, "exit"))[0], 0)
88
+ } finally {
89
+ if (child.exitCode === null) child.kill("SIGKILL")
90
+ await fs.rm(fixture.root, {force: true, recursive: true})
91
+ }
92
+ })
93
+
94
+ test("plain daemon startup remains listener-only with no active release", async () => {
95
+ const fixture = await createFixture()
96
+ const child = spawn(process.execPath, [binPath, "daemon", "--config", fixture.configPath], {stdio: ["pipe", "pipe", "pipe"]})
97
+
98
+ try {
99
+ await waitForLog(child, "control socket listening")
100
+ const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
101
+
102
+ assert.equal(status.activeReleaseId, null)
103
+ assert.deepEqual(status.releases, [])
104
+
105
+ child.kill("SIGTERM")
106
+ assert.equal((await once(child, "exit"))[0], 0)
107
+ } finally {
108
+ if (child.exitCode === null) child.kill("SIGKILL")
109
+ await fs.rm(fixture.root, {force: true, recursive: true})
110
+ }
111
+ })
112
+
113
+ test("SIGTERM during bootstrap activation follows the daemon shutdown path", async () => {
114
+ const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 60000})
115
+ const started = waitForFile(fixture.startedPath)
116
+ const child = spawnDaemon(fixture, {releaseId: "slow-release", revision: "slow123"})
117
+
118
+ try {
119
+ const managedPid = Number(await started)
120
+ child.kill("SIGTERM")
121
+
122
+ const [code, signal] = await once(child, "exit")
123
+
124
+ assert.equal(code, 0)
125
+ assert.equal(signal, null)
126
+ assert.equal(await fs.readFile(fixture.stoppedPath, "utf8"), String(managedPid))
127
+ await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
128
+ } finally {
129
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
130
+ await fs.rm(fixture.root, {force: true, recursive: true})
131
+ }
132
+ })
133
+
134
+ test("SIGTERM during multi-process bootstrap owns every process started after shutdown begins", async () => {
135
+ const fixture = await createFixture({multiProcessSignal: true})
136
+ const shutdownStarted = waitForLifecycleEvent(fixture.lifecyclePath, (event) => event.event === "shutdown")
137
+ const child = spawnDaemon(fixture, {releaseId: "multi-release", revision: "multi123"})
138
+ let output = ""
139
+
140
+ child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
141
+
142
+ try {
143
+ await shutdownStarted
144
+ await fs.writeFile(fixture.gatePath, "continue\n")
145
+ const [code, signal] = await once(child, "exit")
146
+ const events = (await fs.readFile(fixture.lifecyclePath, "utf8")).trim().split("\n").map((line) => JSON.parse(line))
147
+ const shutdownIndex = events.findIndex((event) => event.event === "shutdown")
148
+ const startedAfterShutdown = events.slice(shutdownIndex + 1).filter((event) => event.event === "started")
149
+ const records = output.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
150
+ const recordedPids = new Set(records.filter((record) => record.message === "process started").map((record) => record.data?.pid))
151
+
152
+ assert.equal(code, 0)
153
+ assert.equal(signal, null)
154
+ assert.notEqual(shutdownIndex, -1)
155
+ assert.ok(startedAfterShutdown.length > 0, `fixture must start a later bootstrap process after triggering shutdown: ${JSON.stringify(events)}`)
156
+ assert.deepEqual(startedAfterShutdown.filter((event) => !recordedPids.has(event.pid)), [])
157
+ for (const event of startedAfterShutdown) assert.equal(isProcessAlive(event.pid), false, `expected process ${event.pid} to be stopped before daemon exit`)
158
+ } finally {
159
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
160
+
161
+ try {
162
+ const events = (await fs.readFile(fixture.lifecyclePath, "utf8")).trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
163
+
164
+ for (const event of events) {
165
+ if (event.event === "started" && isProcessAlive(event.pid)) process.kill(-event.pid, "SIGKILL")
166
+ }
167
+ } catch {
168
+ // The fixture may exit before creating its lifecycle log.
169
+ }
170
+
171
+ await fs.rm(fixture.root, {force: true, recursive: true})
172
+ }
173
+ })
174
+
175
+ test("failed daemon bootstrap reports a structured failure, cleans its processes, and exits non-zero", async () => {
176
+ const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 100})
177
+
178
+ try {
179
+ const result = await runDaemon([
180
+ "--config", fixture.configPath,
181
+ "--release-path", fixture.root,
182
+ "--release-id", "bad-release",
183
+ "--revision", "bad123"
184
+ ])
185
+ const records = result.output.split("\n").filter(Boolean).map((line) => JSON.parse(line))
186
+ const failure = records.find((record) => record.message === "bootstrap activation failed")
187
+
188
+ assert.notEqual(result.code, 0)
189
+ assert.deepEqual(failure?.data, {releaseId: "bad-release", status: "error"})
190
+ assert.ok(records.some((record) => record.message === "release startup process status" && record.data?.phase === "after cleanup" && record.data?.state === "stopped"))
191
+ await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
192
+ } finally {
193
+ await fs.rm(fixture.root, {force: true, recursive: true})
194
+ }
195
+ })
196
+
197
+ test("daemon bootstrap reports but does not kill a live process from statePath", async () => {
198
+ const fixture = await createFixture({persistState: true})
199
+ const leftover = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"})
200
+
201
+ await once(leftover, "spawn")
202
+ await writeState(fixture.statePath, {
203
+ activeReleaseId: "previous",
204
+ releases: [{processes: [{id: "worker", pid: leftover.pid}], releaseId: "previous"}],
205
+ services: [],
206
+ singletons: []
207
+ })
208
+
209
+ const child = spawnDaemon(fixture, {releaseId: "recovered", revision: "def456"})
210
+
211
+ try {
212
+ await waitForLog(child, "control socket listening")
213
+ const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
214
+
215
+ assert.ok(leftover.pid !== undefined && isProcessAlive(leftover.pid))
216
+ assert.deepEqual(status.orphans, [{id: "worker", pid: leftover.pid, releaseId: "previous"}])
217
+
218
+ child.kill("SIGTERM")
219
+ await once(child, "exit")
220
+
221
+ assert.deepEqual(liveProcesses(await readState(fixture.statePath)), [{id: "worker", pid: leftover.pid, releaseId: "previous"}])
222
+ } finally {
223
+ if (child.exitCode === null) child.kill("SIGKILL")
224
+ leftover.kill("SIGKILL")
225
+ await fs.rm(fixture.root, {force: true, recursive: true})
226
+ }
227
+ })
228
+
229
+ test("failed daemon bootstrap preserves prior live process records in statePath", async () => {
230
+ const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 100, persistState: true})
231
+ const leftover = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"})
232
+
233
+ await once(leftover, "spawn")
234
+ await writeState(fixture.statePath, {
235
+ activeReleaseId: "previous",
236
+ releases: [{processes: [{id: "worker", pid: leftover.pid}], releaseId: "previous"}],
237
+ services: [],
238
+ singletons: []
239
+ })
240
+
241
+ try {
242
+ const result = await runDaemon([
243
+ "--config", fixture.configPath,
244
+ "--release-path", fixture.root,
245
+ "--release-id", "bad-release",
246
+ "--revision", "bad123"
247
+ ])
248
+
249
+ assert.notEqual(result.code, 0)
250
+ assert.deepEqual(liveProcesses(await readState(fixture.statePath)), [{id: "worker", pid: leftover.pid, releaseId: "previous"}])
251
+ } finally {
252
+ leftover.kill("SIGKILL")
253
+ await fs.rm(fixture.root, {force: true, recursive: true})
254
+ }
255
+ })
256
+
257
+ /**
258
+ * @param {{healthGate?: boolean, healthPath?: string, healthTimeoutMs?: number, multiProcessSignal?: boolean, persistState?: boolean}} [options] - Fixture options.
259
+ * @returns {Promise<{configPath: string, gatePath: string, healthGatePath: string, lifecyclePath: string, root: string, socketPath: string, startedPath: string, statePath: string, stoppedPath: string}>} Fixture paths.
260
+ */
261
+ async function createFixture({healthGate = false, healthPath = "/ping", healthTimeoutMs = 1000, multiProcessSignal = false, persistState = false} = {}) {
262
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-bootstrap-"))
263
+ const socketPath = path.join(root, "control.sock")
264
+ const statePath = path.join(root, "state.json")
265
+ const startedPath = path.join(root, "started.pid")
266
+ const stoppedPath = path.join(root, "stopped.pid")
267
+ const lifecyclePath = path.join(root, "lifecycle.jsonl")
268
+ const gatePath = path.join(root, "continue.fifo")
269
+ const healthGatePath = path.join(root, "health-ready")
270
+ const configPath = path.join(root, "rollbridge.js")
271
+ const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`
272
+ const lifecycleEnv = {ROLLBRIDGE_TEST_LIFECYCLE_PATH: lifecyclePath}
273
+ const webEnv = {
274
+ ROLLBRIDGE_TEST_STARTED_PATH: startedPath,
275
+ ROLLBRIDGE_TEST_STOPPED_PATH: stoppedPath,
276
+ ...(healthGate ? {ROLLBRIDGE_TEST_HEALTH_GATE_PATH: healthGatePath} : {})
277
+ }
278
+ const config = {
279
+ application: "bootstrap-test",
280
+ control: {path: socketPath},
281
+ processes: multiProcessSignal ? [
282
+ {command: `trap '' TERM; printf '%s\\n' '{"event":"shutdown"}' >> ${JSON.stringify(lifecyclePath)}; kill -TERM "$ROLLBRIDGE_TEST_DAEMON_PID"; printf '{"event":"started","pid":%s,"processId":"database","replicaIndex":"0"}\\n' "$$" >> ${JSON.stringify(lifecyclePath)}; read ignored < ${JSON.stringify(gatePath)}`, env: lifecycleEnv, id: "database", policy: "service"},
283
+ {command: `exec ${command}`, env: lifecycleEnv, id: "worker", policy: "companion", replicas: 2},
284
+ {command: `exec ${command}`, env: lifecycleEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}
285
+ ] : [{command, env: webEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}],
286
+ proxy: {host: "127.0.0.1", port: 0},
287
+ ...(persistState ? {statePath} : {})
288
+ }
289
+
290
+ const setup = multiProcessSignal ? "process.env.ROLLBRIDGE_TEST_DAEMON_PID = String(process.pid)\n" : ""
291
+
292
+ if (multiProcessSignal) {
293
+ const fifo = spawn("mkfifo", [gatePath])
294
+ const [code] = await once(fifo, "exit")
295
+
296
+ assert.equal(code, 0)
297
+ }
298
+
299
+ await fs.writeFile(configPath, `${setup}module.exports = ${JSON.stringify(config, null, 2)}\n`)
300
+ return {configPath, gatePath, healthGatePath, lifecyclePath, root, socketPath, startedPath, statePath, stoppedPath}
301
+ }
302
+
303
+ /**
304
+ * @param {string} filePath - JSON-lines event path.
305
+ * @param {(event: Record<string, import("../src/json.js").JsonValue>) => boolean} matches - Event predicate.
306
+ * @returns {Promise<Record<string, import("../src/json.js").JsonValue>>} First matching event.
307
+ */
308
+ async function waitForLifecycleEvent(filePath, matches) {
309
+ const watcher = fs.watch(path.dirname(filePath))
310
+
311
+ try {
312
+ for await (const event of watcher) {
313
+ if (event.filename !== path.basename(filePath)) continue
314
+
315
+ const records = (await fs.readFile(filePath, "utf8")).trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
316
+ const match = records.find(matches)
317
+
318
+ if (match) return match
319
+ }
320
+ } finally {
321
+ await watcher.return?.()
322
+ }
323
+
324
+ throw new Error(`File watcher ended before a matching event was written to ${filePath}`)
325
+ }
326
+
327
+ /**
328
+ * @param {string} filePath - File whose creation is the synchronization point.
329
+ * @returns {Promise<string>} File contents once created.
330
+ */
331
+ async function waitForFile(filePath) {
332
+ const watcher = fs.watch(path.dirname(filePath))
333
+
334
+ try {
335
+ for await (const event of watcher) {
336
+ if (event.filename === path.basename(filePath)) return await fs.readFile(filePath, "utf8")
337
+ }
338
+ } finally {
339
+ await watcher.return?.()
340
+ }
341
+
342
+ throw new Error(`File watcher ended before ${filePath} was created`)
343
+ }
344
+
345
+ /**
346
+ * @param {{configPath: string, root: string}} fixture - Fixture paths.
347
+ * @param {{releaseId: string, revision: string}} release - Bootstrap metadata.
348
+ * @returns {import("node:child_process").ChildProcessWithoutNullStreams} Spawned daemon.
349
+ */
350
+ function spawnDaemon(fixture, release) {
351
+ return spawn(process.execPath, [binPath, "daemon", "--config", fixture.configPath, "--release-path", fixture.root, "--release-id", release.releaseId, "--revision", release.revision], {stdio: ["pipe", "pipe", "pipe"]})
352
+ }
353
+
354
+ /**
355
+ * @param {string[]} args - Daemon arguments.
356
+ * @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
357
+ */
358
+ async function runDaemon(args) {
359
+ const child = spawn(process.execPath, [binPath, "daemon", ...args], {stdio: ["ignore", "pipe", "pipe"]})
360
+ let output = ""
361
+ let stderr = ""
362
+
363
+ child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
364
+ child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk })
365
+ const [code] = await once(child, "exit")
366
+
367
+ return {code, output, stderr}
368
+ }
369
+
370
+ /**
371
+ * @param {import("node:child_process").ChildProcessWithoutNullStreams} child - Spawned daemon.
372
+ * @param {string} message - Structured log message to await.
373
+ * @returns {Promise<void>} Resolves after the message is observed.
374
+ */
375
+ async function waitForLog(child, message) {
376
+ child.stdout.setEncoding("utf8")
377
+
378
+ for await (const chunk of child.stdout) {
379
+ if (String(chunk).includes(`"message":"${message}"`)) return
380
+ }
381
+
382
+ throw new Error(`Daemon exited before logging ${message}`)
383
+ }
384
+
385
+ /**
386
+ * @param {Record<string, import("../src/json.js").JsonValue>} status - Daemon status.
387
+ * @param {string} releaseId - Expected release id.
388
+ * @returns {Record<string, import("../src/json.js").JsonValue>} Matching release status.
389
+ */
390
+ function assertRelease(status, releaseId) {
391
+ assert.ok(Array.isArray(status.releases))
392
+ const release = status.releases.find((candidate) => candidate && typeof candidate === "object" && "releaseId" in candidate && candidate.releaseId === releaseId)
393
+
394
+ assert.ok(release && typeof release === "object" && !Array.isArray(release))
395
+ return release
396
+ }
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import crypto from "node:crypto"
4
+ import fs from "node:fs"
4
5
  import http from "node:http"
5
6
 
6
7
  const port = Number(process.env.ROLLBRIDGE_PORT)
@@ -8,9 +9,13 @@ const releaseId = process.env.ROLLBRIDGE_RELEASE_ID || "unknown"
8
9
  const healthFails = releaseId.includes("bad")
9
10
  const sockets = new Set()
10
11
 
12
+ if (process.env.ROLLBRIDGE_TEST_STARTED_PATH) {
13
+ fs.writeFileSync(process.env.ROLLBRIDGE_TEST_STARTED_PATH, String(process.pid))
14
+ }
15
+
11
16
  const server = http.createServer((request, response) => {
12
17
  if (request.url === "/ping") {
13
- if (healthFails) {
18
+ if (healthFails || (process.env.ROLLBRIDGE_TEST_HEALTH_GATE_PATH && !fs.existsSync(process.env.ROLLBRIDGE_TEST_HEALTH_GATE_PATH))) {
14
19
  response.writeHead(500, {"Content-Type": "application/json"})
15
20
  response.end(JSON.stringify({message: "bad release"}))
16
21
  return
@@ -59,6 +64,14 @@ server.on("upgrade", (request, socket) => {
59
64
  })
60
65
 
61
66
  process.on("SIGTERM", () => {
67
+ if (process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH) {
68
+ fs.appendFileSync(process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH, `${JSON.stringify({event: "stopped", pid: process.pid, processId: process.env.ROLLBRIDGE_PROCESS_ID, replicaIndex: process.env.ROLLBRIDGE_REPLICA_INDEX})}\n`)
69
+ }
70
+
71
+ if (process.env.ROLLBRIDGE_TEST_STOPPED_PATH) {
72
+ fs.writeFileSync(process.env.ROLLBRIDGE_TEST_STOPPED_PATH, String(process.pid))
73
+ }
74
+
62
75
  server.close(() => process.exit(0))
63
76
 
64
77
  if (sockets.size === 0) {
@@ -66,4 +79,14 @@ process.on("SIGTERM", () => {
66
79
  }
67
80
  })
68
81
 
69
- server.listen(port, "127.0.0.1")
82
+ const recordStarted = () => {
83
+ if (process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH) {
84
+ fs.appendFileSync(process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH, `${JSON.stringify({event: "started", pid: process.pid, processId: process.env.ROLLBRIDGE_PROCESS_ID, replicaIndex: process.env.ROLLBRIDGE_REPLICA_INDEX})}\n`)
85
+ }
86
+ }
87
+
88
+ if (process.env.ROLLBRIDGE_PORT) {
89
+ server.listen(port, "127.0.0.1", recordStarted)
90
+ } else {
91
+ recordStarted()
92
+ }
@@ -1,12 +1,16 @@
1
1
  // @ts-check
2
2
 
3
3
  import assert from "node:assert/strict"
4
+ import {execFile} from "node:child_process"
4
5
  import fs from "node:fs/promises"
6
+ import os from "node:os"
5
7
  import path from "node:path"
6
8
  import test from "node:test"
7
9
  import {fileURLToPath} from "node:url"
10
+ import {promisify} from "node:util"
8
11
 
9
12
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
13
+ const execFileAsync = promisify(execFile)
10
14
 
11
15
  test("package.json declares publish metadata", async () => {
12
16
  const pkg = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8"))
@@ -27,3 +31,38 @@ test("a LICENSE file matching the declared license exists", async () => {
27
31
  assert.match(license, /MIT License/)
28
32
  assert.match(license, /Copyright \(c\) \d{4} kaspernj/)
29
33
  })
34
+
35
+ test("package manifest excludes unexpected operational and coverage files", async (t) => {
36
+ const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-pack-"))
37
+ t.after(() => fs.rm(fixtureRoot, {force: true, recursive: true}))
38
+
39
+ await fs.cp(repoRoot, fixtureRoot, {
40
+ filter: (source) => {
41
+ const relative = path.relative(repoRoot, source)
42
+ return relative !== ".git" && relative !== "node_modules" && relative !== "tmp"
43
+ },
44
+ recursive: true,
45
+ })
46
+
47
+ const unexpectedTmpPath = path.join(fixtureRoot, "tmp", "worker-control", "unexpected-transcript.jsonl")
48
+ const unexpectedCoveragePath = path.join(fixtureRoot, "coverage", "unexpected.txt")
49
+ await Promise.all([
50
+ fs.mkdir(path.dirname(unexpectedTmpPath), {recursive: true}),
51
+ fs.mkdir(path.dirname(unexpectedCoveragePath), {recursive: true}),
52
+ ])
53
+ await Promise.all([
54
+ fs.writeFile(unexpectedTmpPath, '{"operational":"state"}\n'),
55
+ fs.writeFile(unexpectedCoveragePath, "unexpected coverage output\n"),
56
+ ])
57
+
58
+ const {stdout} = await execFileAsync("npm", ["pack", "--dry-run", "--json"], {cwd: fixtureRoot})
59
+ /** @type {Array<{path: string}>} */
60
+ const packageFiles = JSON.parse(stdout)[0].files
61
+ const packagePaths = packageFiles.map((file) => file.path)
62
+
63
+ for (const requiredPath of ["LICENSE", "README.md", "bin/rollbridge", "package.json", "src/cli.js"]) {
64
+ assert.ok(packagePaths.includes(requiredPath), `expected package to include ${requiredPath}`)
65
+ }
66
+ assert.ok(!packagePaths.some((packagePath) => packagePath === "tmp" || packagePath.startsWith("tmp/")))
67
+ assert.ok(!packagePaths.some((packagePath) => packagePath === "coverage" || packagePath.startsWith("coverage/")))
68
+ })