rollbridge 0.1.11 → 0.1.13

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 (31) hide show
  1. package/README.md +22 -2
  2. package/docs/cli.md +31 -0
  3. package/docs/config.md +22 -0
  4. package/package.json +1 -1
  5. package/src/cli.js +59 -1
  6. package/src/config.js +10 -1
  7. package/src/daemon.js +82 -28
  8. package/src/release-group.js +38 -2
  9. package/src/state-store.js +8 -0
  10. package/test/config-path.test.js +16 -1
  11. package/test/daemon-bootstrap.test.js +363 -0
  12. package/test/fixtures/dummy-app.js +24 -1
  13. package/test/rollbridge.test.js +92 -3
  14. package/tmp/worker-control/rollbridge-bootstrap/activity-3.jsonl +28 -0
  15. package/tmp/worker-control/rollbridge-bootstrap/current-head-review-activity.jsonl +13 -0
  16. package/tmp/worker-control/rollbridge-bootstrap/current-head-review-transcript.jsonl +1 -0
  17. package/tmp/worker-control/rollbridge-bootstrap/fs-probe-10.jsonl +1 -0
  18. package/tmp/worker-control/rollbridge-bootstrap/plan.md +9 -0
  19. package/tmp/worker-control/rollbridge-bootstrap/repair-activity.jsonl +3 -0
  20. package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-activity.jsonl +25 -0
  21. package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-transcript.jsonl +1 -0
  22. package/tmp/worker-control/rollbridge-bootstrap/repair-transcript.jsonl +1 -0
  23. package/tmp/worker-control/rollbridge-bootstrap/review-activity.jsonl +14 -0
  24. package/tmp/worker-control/rollbridge-bootstrap/review-transcript.jsonl +1 -0
  25. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-activity.jsonl +3 -0
  26. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-activity.jsonl +38 -0
  27. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-transcript.jsonl +1 -0
  28. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-transcript.jsonl +1 -0
  29. package/tmp/worker-control/rollbridge-bootstrap/transcript-2.jsonl +1 -0
  30. package/tmp/worker-control/rollbridge-bootstrap/transcript-3.jsonl +1 -0
  31. package/tmp/worker-control/rollbridge-bootstrap/transcript.jsonl +1 -0
package/README.md CHANGED
@@ -399,6 +399,22 @@ 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 and control listeners, activates the release
413
+ through the normal deploy path, and stays foreground. A failed activation stops
414
+ only processes started by that attempt and exits non-zero; persisted processes
415
+ from a previous daemon are reported as orphans and are never recovered or killed
416
+ implicitly; their live PID records remain in `statePath` for explicit recovery.
417
+
402
418
  Start the daemon only when it is not already running:
403
419
 
404
420
  ```bash
@@ -543,8 +559,12 @@ so its output goes to the journal (`journalctl -u rollbridge`). Key directives:
543
559
  The daemon is long-lived and survives deploys. **Deploy with
544
560
  `rollbridge deploy` (or `rollbridge deploy --ensure-daemon`), not
545
561
  `systemctl restart`** — pointing `--config` at a stable, daemon-wide file while
546
- release paths are passed per deploy. Use `command -v rollbridge` to find the
547
- absolute CLI path for `ExecStart`.
562
+ release paths are passed per deploy. The daemon reloads compatible process and
563
+ lifecycle config before each deploy, so updated graceful-stop deadlines govern
564
+ the release being retired without interrupting the stable proxy. Listener and
565
+ process-topology changes still require a daemon restart; see
566
+ [`docs/config.md`](docs/config.md#config-reloads). Use `command -v rollbridge`
567
+ to find the absolute CLI path for `ExecStart`.
548
568
 
549
569
  See [`docs/logging.md`](docs/logging.md) for where the daemon's JSON logs go
550
570
  (stdout / journald / the `--daemon-log-path` file) and how to rotate them — the
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,28 @@ 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 listeners and activates that
39
+ exact release through the same deploy path used by the control socket: services,
40
+ companions, the proxied process and health check, traffic switching, singletons,
41
+ and service-template refresh. It then remains in the foreground with the normal
42
+ signal behavior.
43
+
44
+ Bootstrap paths must be absolute and normalized, the release path must be an
45
+ accessible directory, and release id/revision values accept letters, numbers,
46
+ dots, underscores, and hyphens (maximum 200 characters, beginning with a letter
47
+ or number). Supplying only some bootstrap options, or an invalid value, exits
48
+ non-zero before listeners start. Activation failure emits a structured
49
+ `bootstrap activation failed` event, cleans up processes owned by that attempt,
50
+ and exits non-zero without inventing an active release. `statePath` entries from
51
+ a previous daemon remain advisory orphans: bootstrap never runs recovery and
52
+ never signals those processes, and retains their live PID records in `statePath`
53
+ for explicit recovery.
54
+
55
+ With no release options, daemon behavior is unchanged: it starts listener-only
56
+ and waits for control-socket deployments.
57
+
35
58
  ## `ensure-daemon`
36
59
 
37
60
  ```
@@ -72,6 +95,14 @@ traffic to it, then drains and stops the previous release. Prints
72
95
  If the new release fails to start or health-check, the previous release stays
73
96
  active and the command errors.
74
97
 
98
+ Before each deploy, the daemon reloads the config path it was started with.
99
+ Compatible process and lifecycle changes apply to the new release and govern
100
+ how the previous release retires, including updated `nonBlockingDrain`,
101
+ `stopSignal`, `lifecycle`, and `gracefulStopMs` settings. The daemon adopts the
102
+ new config only after the replacement release starts successfully. Changes to
103
+ daemon-owned listeners or process topology fail the deploy with a restart
104
+ instruction; see [Config reloads](config.md#config-reloads).
105
+
75
106
  - `--release-path <path>` (**required**) — path to the prepared release
76
107
  directory; available to process templates as `{{releasePath}}`.
77
108
  - `--release-id <id>` — identifier for the release. Defaults to `--revision`,
package/docs/config.md CHANGED
@@ -17,6 +17,28 @@ export default {
17
17
  }
18
18
  ```
19
19
 
20
+ ## Config reloads
21
+
22
+ The daemon reloads the config file it was started with before every deploy and
23
+ rollback. Compatible changes apply to the replacement release, daemon-wide
24
+ service restart definitions, and the retirement of the previous release. This
25
+ means changes to process commands, environment, health checks, lifecycle hooks,
26
+ `nonBlockingDrain`, stop signals, graceful-stop timeouts, restart policies, and
27
+ memory supervision do not require restarting the stable proxy daemon. Updated
28
+ retirement settings also apply to processes that were started by the previous
29
+ release.
30
+
31
+ The replacement config is adopted only after the new release starts and passes
32
+ its health check. Invalid config, an incompatible change, or a failed release
33
+ leaves the previous config and release active.
34
+
35
+ Settings that own daemon listeners or change the managed-process topology still
36
+ require a daemon restart: `application`, `control`, `statePath`, `proxy.host`,
37
+ `proxy.port`, `proxy.upstreamHost`, and changes to process ids, count, `policy`,
38
+ `deployStrategy`, `replicas`, or `port`. A deploy with one of these changes
39
+ fails before starting the replacement and names the settings that require a
40
+ restart.
41
+
20
42
  ## Top-level fields
21
43
 
22
44
  | Field | Type | Default | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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,10 +30,14 @@ 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
- const daemon = new RollbridgeDaemon({config})
40
+ const daemon = new RollbridgeDaemon({config, configPath})
37
41
 
38
42
  await daemon.start()
39
43
 
@@ -44,6 +48,17 @@ 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
+ } catch {
56
+ daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error"})
57
+ await daemon.shutdown()
58
+ process.exitCode = 1
59
+ return
60
+ }
61
+ }
47
62
  })
48
63
 
49
64
  program
@@ -696,6 +711,49 @@ async function validateConfigFile(configPath) {
696
711
  }
697
712
  }
698
713
 
714
+ /**
715
+ * Validates the daemon's optional all-or-nothing bootstrap release interface before
716
+ * config loading or listener startup.
717
+ * @param {{config?: string, releaseId?: string, releasePath?: string, revision?: string}} options - Daemon CLI options.
718
+ * @returns {Promise<{releaseId: string, releasePath: string, revision: string} | undefined>} Validated bootstrap metadata.
719
+ */
720
+ async function validateDaemonBootstrapOptions(options) {
721
+ const bootstrapValues = [options.releasePath, options.releaseId, options.revision]
722
+ const bootstrapRequested = bootstrapValues.some((value) => value !== undefined)
723
+
724
+ if (!bootstrapRequested) return undefined
725
+
726
+ if (!options.config || bootstrapValues.some((value) => value === undefined)) {
727
+ throw new Error("Daemon bootstrap options --config, --release-path, --release-id, and --revision must be provided together.")
728
+ }
729
+
730
+ if (!path.isAbsolute(options.config)) throw new Error("Daemon bootstrap --config must be an absolute path.")
731
+ if (path.normalize(options.config) !== options.config) throw new Error("Daemon bootstrap --config must be normalized and must not contain unsafe traversal segments.")
732
+ if (!path.isAbsolute(/** @type {string} */ (options.releasePath))) throw new Error("Daemon bootstrap --release-path must be an absolute path.")
733
+ 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.")
734
+
735
+ const releaseId = /** @type {string} */ (options.releaseId)
736
+ const revision = /** @type {string} */ (options.revision)
737
+ const safeIdentifier = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/
738
+
739
+ 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.")
740
+ 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.")
741
+
742
+ let releaseStat
743
+
744
+ try {
745
+ releaseStat = await fsPromises.stat(/** @type {string} */ (options.releasePath))
746
+ } catch (error) {
747
+ const reason = error instanceof Error ? error.message : String(error)
748
+
749
+ throw new Error(`Daemon bootstrap --release-path is not accessible: ${reason}`, {cause: error})
750
+ }
751
+
752
+ if (!releaseStat.isDirectory()) throw new Error("Daemon bootstrap --release-path must name a directory.")
753
+
754
+ return {releaseId, releasePath: /** @type {string} */ (options.releasePath), revision}
755
+ }
756
+
699
757
  /**
700
758
  * Starts a daemon when needed and waits until it accepts status commands.
701
759
  * @param {object} args - Options.
package/src/config.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
 
3
3
  import fs from "node:fs/promises"
4
+ import {createHash} from "node:crypto"
5
+ import {createRequire} from "node:module"
4
6
  import os from "node:os"
5
7
  import path from "node:path"
6
8
  import {pathToFileURL} from "node:url"
@@ -27,6 +29,7 @@ import {pathToFileURL} from "node:url"
27
29
 
28
30
  const PROCESS_POLICIES = new Set(["proxied", "companion", "singleton", "service"])
29
31
  const DEFAULT_CONFIG_FILENAMES = ["rollbridge.js"]
32
+ const commonJsRequire = createRequire(import.meta.url)
30
33
 
31
34
  /**
32
35
  * Imports a JavaScript config module without validating it.
@@ -38,7 +41,13 @@ const DEFAULT_CONFIG_FILENAMES = ["rollbridge.js"]
38
41
  */
39
42
  export async function parseConfigFile(configPath) {
40
43
  const absolutePath = path.resolve(configPath)
41
- const moduleNamespace = await import(pathToFileURL(absolutePath).href)
44
+ const configUrl = pathToFileURL(absolutePath)
45
+ const configSource = await fs.readFile(absolutePath)
46
+
47
+ configUrl.searchParams.set("rollbridgeConfig", createHash("sha256").update(configSource).digest("hex"))
48
+ delete commonJsRequire.cache[commonJsRequire.resolve(absolutePath)]
49
+
50
+ const moduleNamespace = await import(configUrl.href)
42
51
  const exported = moduleNamespace.default
43
52
 
44
53
  if (exported === undefined) {
package/src/daemon.js CHANGED
@@ -3,7 +3,9 @@
3
3
  import fs from "node:fs/promises"
4
4
  import http from "node:http"
5
5
  import net from "node:net"
6
+ import {isDeepStrictEqual} from "node:util"
6
7
  import httpProxy from "http-proxy"
8
+ import {loadConfig} from "./config.js"
7
9
  import EventLog from "./event-log.js"
8
10
  import ReleaseGroup from "./release-group.js"
9
11
  import {clearState, isProcessAlive, liveProcesses, readState, writeState} from "./state-store.js"
@@ -23,10 +25,12 @@ export default class RollbridgeDaemon {
23
25
  /**
24
26
  * @param {object} args - Options.
25
27
  * @param {import("./config.js").RollbridgeConfig} args.config - Rollbridge config.
28
+ * @param {string} [args.configPath] - Config file path to reload before deploys.
26
29
  * @param {(message: string, data?: Record<string, JsonValue>) => void} [args.logger] - Logger.
27
30
  */
28
- constructor({config, logger}) {
31
+ constructor({config, configPath, logger}) {
29
32
  this.config = config
33
+ this.configPath = configPath
30
34
  this.eventLog = new EventLog(EVENT_HISTORY_LIMIT)
31
35
 
32
36
  const baseLogger = logger || ((message, data = {}) => console.log(JSON.stringify({at: new Date().toISOString(), data, message})))
@@ -52,6 +56,7 @@ export default class RollbridgeDaemon {
52
56
  this.statePath = config.statePath
53
57
  this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
54
58
  this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
59
+ this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
55
60
  // Still-alive managed processes left by a previous daemon (from statePath), captured at
56
61
  // startup and surfaced in status(). The daemon cannot re-manage them, only report them.
57
62
  this.orphans = /** @type {{id: string, pid: number, releaseId: string | null}[]} */ ([])
@@ -319,30 +324,42 @@ export default class RollbridgeDaemon {
319
324
  async deploy({releaseId, releasePath, revision}) {
320
325
  if (this.stopping) throw new Error("Rollbridge is shutting down")
321
326
 
327
+ const nextConfig = this.configPath ? await loadConfig(this.configPath) : this.config
328
+
329
+ this.assertReloadCompatible(nextConfig)
330
+
322
331
  const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
323
332
  const release = new ReleaseGroup({
324
- config: this.config,
333
+ config: nextConfig,
325
334
  logger: this.logger,
326
335
  releaseId: newReleaseId,
327
336
  releasePath,
328
337
  revision,
329
- servicePorts: this.servicePorts
338
+ servicePorts: this.servicePorts,
339
+ shouldStart: () => !this.stopping
330
340
  })
331
341
 
332
342
  this.logger("deploy starting", {releaseId: newReleaseId, releasePath, revision})
333
343
  const startedServices = /** @type {string[]} */ ([])
334
344
 
345
+ this.startingReleases.add(release)
346
+
335
347
  try {
336
348
  await this.ensureServices(release, startedServices)
337
349
  await release.start()
350
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
338
351
  } catch (error) {
339
352
  this.logger("deploy failed", {error: error instanceof Error ? error.message : String(error), releaseId: newReleaseId})
353
+ await release.stop()
340
354
  await this.stopStartedServices(startedServices)
341
355
  throw error
356
+ } finally {
357
+ this.startingReleases.delete(release)
342
358
  }
343
359
 
344
360
  const previousRelease = this.activeRelease
345
361
 
362
+ this.config = nextConfig
346
363
  this.releases.set(release.releaseId, release)
347
364
  release.activate()
348
365
  this.activeRelease = release
@@ -352,7 +369,7 @@ export default class RollbridgeDaemon {
352
369
  await this.replaceSingletons(release)
353
370
 
354
371
  if (previousRelease) {
355
- void this.drainAndPrune(previousRelease)
372
+ void this.drainAndPrune(previousRelease, nextConfig)
356
373
  }
357
374
 
358
375
  this.persistState()
@@ -363,6 +380,45 @@ export default class RollbridgeDaemon {
363
380
  }
364
381
  }
365
382
 
383
+ /**
384
+ * Rejects config changes that require rebinding daemon-owned resources or changing process topology.
385
+ * @param {import("./config.js").RollbridgeConfig} nextConfig - Freshly loaded config.
386
+ * @returns {void}
387
+ */
388
+ assertReloadCompatible(nextConfig) {
389
+ /** @type {string[]} */
390
+ const restartRequired = []
391
+
392
+ if (nextConfig.application !== this.config.application) restartRequired.push("application")
393
+ if (!isDeepStrictEqual(nextConfig.control, this.config.control)) restartRequired.push("control")
394
+ if (nextConfig.statePath !== this.config.statePath) restartRequired.push("statePath")
395
+
396
+ if (nextConfig.proxy.host !== this.config.proxy.host) restartRequired.push("proxy.host")
397
+ if (nextConfig.proxy.port !== this.config.proxy.port) restartRequired.push("proxy.port")
398
+ if (nextConfig.proxy.upstreamHost !== this.config.proxy.upstreamHost) restartRequired.push("proxy.upstreamHost")
399
+
400
+ if (nextConfig.processes.length !== this.config.processes.length) {
401
+ restartRequired.push("processes")
402
+ } else {
403
+ for (const processConfig of this.config.processes) {
404
+ const nextProcessConfig = nextConfig.processes.find((candidate) => candidate.id === processConfig.id)
405
+
406
+ if (!nextProcessConfig ||
407
+ nextProcessConfig.policy !== processConfig.policy ||
408
+ nextProcessConfig.deployStrategy !== processConfig.deployStrategy ||
409
+ nextProcessConfig.replicas !== processConfig.replicas ||
410
+ !isDeepStrictEqual(nextProcessConfig.port, processConfig.port)) {
411
+ restartRequired.push("processes")
412
+ break
413
+ }
414
+ }
415
+ }
416
+
417
+ if (restartRequired.length > 0) {
418
+ throw new Error(`Config changes to ${restartRequired.join(", ")} cannot be applied live; restart the Rollbridge daemon before deploying.`)
419
+ }
420
+ }
421
+
366
422
  /**
367
423
  * Rolls back to a previously-active release by re-running the deploy flow on its
368
424
  * retained metadata: it re-starts the target release, health-checks it, switches
@@ -418,7 +474,8 @@ export default class RollbridgeDaemon {
418
474
  async ensureServices(release, startedServices) {
419
475
  await release.allocatePorts()
420
476
 
421
- for (const processConfig of this.config.processes) {
477
+ for (const processConfig of release.config.processes) {
478
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
422
479
  if (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff") continue
423
480
  if (this.services.has(processConfig.id)) continue
424
481
 
@@ -438,6 +495,8 @@ export default class RollbridgeDaemon {
438
495
  delete this.servicePorts[processConfig.id]
439
496
  throw error
440
497
  }
498
+
499
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
441
500
  }
442
501
  }
443
502
 
@@ -473,20 +532,7 @@ export default class RollbridgeDaemon {
473
532
 
474
533
  const nextDefinition = release.buildProcess(processConfig, {shouldRestart: () => !this.stopping})
475
534
 
476
- service.updateDefinition({
477
- command: nextDefinition.command,
478
- cwd: nextDefinition.cwd,
479
- env: nextDefinition.env,
480
- lifecycle: nextDefinition.lifecycle,
481
- logger: nextDefinition.logger,
482
- memory: nextDefinition.memory,
483
- outputLines: nextDefinition.outputLines,
484
- restart: nextDefinition.restart,
485
- restartDelayMs: nextDefinition.restartDelayMs,
486
- shouldRestart: nextDefinition.shouldRestart,
487
- stopSignal: nextDefinition.stopSignal,
488
- stopTimeoutMs: nextDefinition.stopTimeoutMs
489
- })
535
+ service.updateDefinition(nextDefinition)
490
536
  }
491
537
  }
492
538
 
@@ -497,12 +543,14 @@ export default class RollbridgeDaemon {
497
543
  */
498
544
  async replaceSingletons(release) {
499
545
  for (const processConfig of this.config.processes) {
546
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
500
547
  if (processConfig.policy !== "singleton") continue
501
548
 
502
549
  const previous = this.singletons.get(processConfig.id)
503
550
 
504
551
  if (previous) {
505
552
  await previous.stop()
553
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
506
554
  }
507
555
 
508
556
  const singleton = release.buildProcess(processConfig)
@@ -611,11 +659,12 @@ export default class RollbridgeDaemon {
611
659
  /**
612
660
  * Drains and stops a retired release in the background, then prunes stopped releases.
613
661
  * @param {ReleaseGroup} release - Release to drain and stop.
662
+ * @param {import("./config.js").RollbridgeConfig} [config] - Refreshed config governing retirement.
614
663
  * @returns {Promise<void>} Resolves once drained, stopped, and pruned.
615
664
  */
616
- async drainAndPrune(release) {
665
+ async drainAndPrune(release, config = this.config) {
617
666
  try {
618
- await release.drainAndStop(this.config.proxy.drainTimeoutMs)
667
+ await release.drainAndStop(config.proxy.drainTimeoutMs, config)
619
668
  this.logger("release drained", {releaseId: release.releaseId})
620
669
  } catch (error) {
621
670
  this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: release.releaseId})
@@ -652,9 +701,7 @@ export default class RollbridgeDaemon {
652
701
  if (!this.statePath || this.stopping) return
653
702
 
654
703
  const statePath = this.statePath
655
- // Drop the orphans view from the snapshot: it reflects a *previous* daemon's leftovers, not
656
- // this daemon's own managed state, and is recomputed from the persisted processes on restart.
657
- const {orphans: _orphans, ...status} = this.status()
704
+ const status = this.status()
658
705
  const snapshot = {...status, events: this.eventLog.recent(), persistedAt: new Date().toISOString()}
659
706
 
660
707
  // Serialize writes (and track the tail) so shutdown can wait for an in-flight write before
@@ -706,17 +753,24 @@ export default class RollbridgeDaemon {
706
753
  this.proxy.close()
707
754
  await Promise.allSettled([...this.services.values()].map((processInstance) => processInstance.stop()))
708
755
  await Promise.allSettled([...this.singletons.values()].map((processInstance) => processInstance.stop()))
756
+ await Promise.allSettled([...this.startingReleases].map((release) => release.stop()))
709
757
  await Promise.allSettled([...this.releases.values()].map((release) => release.stop()))
710
758
  await this.closeServer(this.proxyServer)
711
759
  await this.closeServer(this.controlServer)
712
760
  await fs.rm(this.config.control.path, {force: true})
713
761
 
714
- // A clean shutdown leaves no orphans, so remove the state file rather than leaving stale
715
- // pids. Wait for any in-flight write first so it can't recreate the file afterward (no new
716
- // writes start: stopping is set and the persist timer is cleared above).
762
+ // Wait for any in-flight write first so it can't recreate or overwrite the final state (no
763
+ // new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
764
+ // orphans are not owned by this daemon, so retain their records until they are confirmed gone.
717
765
  if (this.statePath) {
718
766
  if (this.pendingWrite) await this.pendingWrite
719
- await clearState(this.statePath)
767
+ const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
768
+
769
+ if (orphans.length > 0) {
770
+ await writeState(this.statePath, {activeReleaseId: null, orphans, releases: [], services: [], singletons: []})
771
+ } else {
772
+ await clearState(this.statePath)
773
+ }
720
774
  }
721
775
  }
722
776
 
@@ -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) {
@@ -232,6 +238,34 @@ export default class ReleaseGroup extends EventEmitter {
232
238
  })
233
239
  }
234
240
 
241
+ /**
242
+ * Applies refreshed process definitions before retiring this release.
243
+ * @param {import("./config.js").RollbridgeConfig} config - Current deployment config.
244
+ * @returns {void}
245
+ */
246
+ refreshProcessDefinitions(config) {
247
+ this.config = config
248
+ this.handoffServiceIds.clear()
249
+ this.nonBlockingDrainIds.clear()
250
+
251
+ for (const processConfig of config.processes) {
252
+ const instances = this.getProcesses(processConfig.id)
253
+
254
+ for (let index = 0; index < instances.length; index += 1) {
255
+ const instance = instances[index]
256
+ const nextDefinition = this.buildProcess(processConfig, {
257
+ count: processConfig.replicas,
258
+ index,
259
+ instanceId: instance.id
260
+ })
261
+
262
+ instance.process.updateDefinition(nextDefinition)
263
+ if (processConfig.policy === "service" && processConfig.deployStrategy === "handoff") this.handoffServiceIds.add(instance.id)
264
+ if (processConfig.nonBlockingDrain) this.nonBlockingDrainIds.add(instance.id)
265
+ }
266
+ }
267
+ }
268
+
235
269
  /**
236
270
  * @param {import("./config.js").ProcessConfig} processConfig - Process config.
237
271
  * @param {{count: number, index: number}} replica - Replica index and total count.
@@ -325,13 +359,15 @@ export default class ReleaseGroup extends EventEmitter {
325
359
  /**
326
360
  * Starts draining and stops once existing connections close or timeout.
327
361
  * @param {number} timeoutMs - Drain timeout.
362
+ * @param {import("./config.js").RollbridgeConfig} [config] - Refreshed config governing retirement.
328
363
  * @returns {Promise<void>} Resolves when stopped.
329
364
  */
330
- async drainAndStop(timeoutMs) {
365
+ async drainAndStop(timeoutMs, config = this.config) {
331
366
  if (this.state === "stopped") return
332
367
 
333
368
  this.state = "draining"
334
369
  this.drainStartedAt = new Date().toISOString()
370
+ this.refreshProcessDefinitions(config)
335
371
 
336
372
  // Stop nonBlockingDrain processes (e.g. job workers) immediately and in the background, so
337
373
  // their lifecycle drain runs as soon as the release is retired — in parallel with the
@@ -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})
@@ -5,7 +5,7 @@ import fs from "node:fs/promises"
5
5
  import os from "node:os"
6
6
  import path from "node:path"
7
7
  import test from "node:test"
8
- import {resolveConfigPath} from "../src/config.js"
8
+ import {loadConfig, resolveConfigPath} from "../src/config.js"
9
9
  import {runCli} from "../src/cli.js"
10
10
 
11
11
  const validConfig = {
@@ -65,6 +65,21 @@ test("resolveConfigPath throws an actionable error when no default config exists
65
65
  }
66
66
  })
67
67
 
68
+ test("loadConfig reloads a changed CommonJS module", async () => {
69
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
70
+ const configPath = await writeConfigModule(dir)
71
+
72
+ try {
73
+ assert.equal((await loadConfig(configPath)).application, "demo")
74
+
75
+ await fs.writeFile(configPath, `module.exports = ${JSON.stringify({...validConfig, application: "updated"}, null, 2)}\n`)
76
+
77
+ assert.equal((await loadConfig(configPath)).application, "updated")
78
+ } finally {
79
+ await fs.rm(dir, {force: true, recursive: true})
80
+ }
81
+ })
82
+
68
83
  test("validate CLI command resolves the default config when --config is omitted", async () => {
69
84
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
70
85
  const originalCwd = process.cwd()