rollbridge 0.1.19 → 0.1.21

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
@@ -412,6 +412,15 @@ rollbridge daemon --config /srv/ticket-server/rollbridge.js \
412
412
  --boot-attestation sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
413
413
  ```
414
414
 
415
+ External supervisors that need to replace a foreground owner without waiting
416
+ for its workers to drain add `--takeover-owner`. The candidate starts and
417
+ health-checks the exact release first. Only then does it retire the accepted
418
+ owner's proxy/control listeners; the retired daemon keeps its already-accepted
419
+ workers until their ordinary drain completes while the attested replacement
420
+ binds the stable listeners. Candidate bootstrap failure leaves the accepted
421
+ owner untouched. This is opt-in; ordinary daemon bootstrap and `shutdown` keep
422
+ their existing behavior.
423
+
415
424
  The four bootstrap inputs are all-or-nothing and use absolute config/release
416
425
  paths. Rollbridge binds its proxy, activates the release through the normal
417
426
  deploy path, then exposes the control socket and stays foreground. A failed
package/docs/cli.md CHANGED
@@ -23,7 +23,7 @@ process-policy details.
23
23
  ## `daemon`
24
24
 
25
25
  ```
26
- rollbridge daemon [--config <path>]
26
+ rollbridge daemon [--config <path>] [--takeover-owner]
27
27
  [--release-path <path> --release-id <id> --revision <sha>
28
28
  [--boot-attestation <sha256:digest>]]
29
29
  ```
@@ -64,6 +64,13 @@ identity. Rollbridge does not calculate or interpret the digest.
64
64
  With no release options, daemon behavior is unchanged: it starts listener-only
65
65
  and waits for control-socket deployments.
66
66
 
67
+ `--takeover-owner` requires the complete bootstrap tuple. It bootstraps and
68
+ health-checks the replacement before sending the current daemon the private
69
+ retirement command. Retirement stops stable listeners and new work promptly,
70
+ but does not wait for owned workers' normal drain before the replacement binds
71
+ the proxy and control socket. A bootstrap failure occurs before retirement, so
72
+ the previously accepted owner remains available.
73
+
67
74
  ## `ensure-daemon`
68
75
 
69
76
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
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
@@ -36,6 +36,7 @@ export async function runCli(argv) {
36
36
  .option("--release-id <id>", "Bootstrap release id (requires --config, --release-path, and --revision)")
37
37
  .option("--revision <sha>", "Bootstrap revision (requires --config, --release-path, and --release-id)")
38
38
  .option("--boot-attestation <digest>", "Opaque bootstrap ownership attestation (requires the complete bootstrap release tuple)")
39
+ .option("--takeover-owner", "Boot and health-check before retiring the current external owner")
39
40
  .action(async (options) => {
40
41
  const bootstrap = await validateDaemonBootstrapOptions(options)
41
42
  const configPath = await resolveConfigPath(options.config)
@@ -43,7 +44,9 @@ export async function runCli(argv) {
43
44
  const runtime = await loadDaemonRuntimeIdentity(process.env.ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST)
44
45
  const daemon = new RollbridgeDaemon({bootstrap, config, configPath, runtime})
45
46
 
46
- await daemon.start({exposeControl: !bootstrap})
47
+ if (options.takeoverOwner && (!bootstrap || !bootstrap.attestation)) throw new Error("Daemon --takeover-owner requires the complete bootstrap release tuple and --boot-attestation.")
48
+
49
+ if (!options.takeoverOwner) await daemon.start({exposeControl: !bootstrap})
47
50
 
48
51
  const shutdown = async () => {
49
52
  await daemon.shutdown()
@@ -56,10 +59,15 @@ export async function runCli(argv) {
56
59
  if (bootstrap) {
57
60
  try {
58
61
  await daemon.deploy(bootstrap)
59
- await daemon.exposeControl()
62
+ if (options.takeoverOwner) {
63
+ await sendControlCommand({command: {attestation: bootstrap.attestation, command: "retire-owner"}, path: config.control.path})
64
+ await daemon.start({reportOrphans: false})
65
+ } else {
66
+ await daemon.exposeControl()
67
+ }
60
68
  } catch {
61
69
  daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error"})
62
- await daemon.shutdown()
70
+ if (!options.takeoverOwner) await daemon.shutdown()
63
71
  process.exitCode = 1
64
72
  return
65
73
  }
package/src/daemon.js CHANGED
@@ -61,8 +61,10 @@ export default class RollbridgeDaemon {
61
61
  this.stopping = false
62
62
  this.statePath = config.statePath
63
63
  this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
64
+ this.persistenceEnabled = false
64
65
  this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
65
66
  this.shutdownPromise = /** @type {Promise<void> | undefined} */ (undefined)
67
+ this.retirementPromise = /** @type {Promise<void> | undefined} */ (undefined)
66
68
  this.controlClosePromise = /** @type {Promise<void> | undefined} */ (undefined)
67
69
  this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
68
70
  // Still-alive managed processes left by a previous daemon (from statePath), captured at
@@ -74,11 +76,11 @@ export default class RollbridgeDaemon {
74
76
 
75
77
  /**
76
78
  * Starts daemon listeners.
77
- * @param {{exposeControl?: boolean}} [options] - Whether to expose the control socket immediately.
79
+ * @param {{exposeControl?: boolean, reportOrphans?: boolean}} [options] - Listener startup options.
78
80
  * @returns {Promise<void>} Resolves when the requested listeners are ready.
79
81
  */
80
- async start({exposeControl = true} = {}) {
81
- await this.reportOrphans()
82
+ async start({exposeControl = true, reportOrphans = true} = {}) {
83
+ if (reportOrphans) await this.reportOrphans()
82
84
  await this.startProxy()
83
85
  if (exposeControl) await this.exposeControl()
84
86
  }
@@ -358,6 +360,13 @@ export default class RollbridgeDaemon {
358
360
  return {message: "shutdown"}
359
361
  }
360
362
 
363
+ if (commandName === "retire-owner") {
364
+ const attestation = requiredString(data.attestation, "attestation")
365
+ if (!/^sha256:[a-f0-9]{64}$/.test(attestation)) throw new Error("Owner retirement attestation must use the canonical sha256:<64 lowercase hex> format")
366
+ await this.retireOwner({attestation, completionSocket: controlSocket})
367
+ return {message: "owner retired"}
368
+ }
369
+
361
370
  throw new Error(`Unknown command: ${String(commandName)}`)
362
371
  }
363
372
 
@@ -732,6 +741,7 @@ export default class RollbridgeDaemon {
732
741
  startStatePersistence() {
733
742
  if (!this.statePath) return
734
743
 
744
+ this.persistenceEnabled = true
735
745
  this.persistState()
736
746
  this.persistTimer = setInterval(() => this.persistState(), STATE_PERSIST_INTERVAL_MS)
737
747
  this.persistTimer.unref?.()
@@ -743,7 +753,7 @@ export default class RollbridgeDaemon {
743
753
  * @returns {void}
744
754
  */
745
755
  persistState() {
746
- if (!this.statePath || this.stopping) return
756
+ if (!this.statePath || !this.persistenceEnabled || this.stopping) return
747
757
 
748
758
  const statePath = this.statePath
749
759
  const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
@@ -797,6 +807,50 @@ export default class RollbridgeDaemon {
797
807
  if (waitForControlConnections && this.controlClosePromise) await this.controlClosePromise
798
808
  }
799
809
 
810
+ /**
811
+ * Relinquishes stable listeners promptly while retaining draining children under
812
+ * this daemon until their normal stop contract completes.
813
+ * @param {{attestation: string, completionSocket?: net.Socket}} options - Attested handoff request.
814
+ * @returns {Promise<void>} Resolves once a replacement can exclusively bind listeners.
815
+ */
816
+ async retireOwner({attestation, completionSocket}) {
817
+ if (this.retirementPromise) return await this.retirementPromise
818
+ this.retirementPromise = this.performOwnerRetirement(attestation, completionSocket)
819
+ return await this.retirementPromise
820
+ }
821
+
822
+ /**
823
+ * @param {string} attestation - Replacement boot attestation.
824
+ * @param {net.Socket | undefined} completionSocket - Requesting handoff connection.
825
+ * @returns {Promise<void>} Resolves after quiesce and listener release.
826
+ */
827
+ async performOwnerRetirement(attestation, completionSocket) {
828
+ this.stopping = true
829
+ if (this.persistTimer) {
830
+ clearInterval(this.persistTimer)
831
+ this.persistTimer = undefined
832
+ }
833
+ this.persistenceEnabled = false
834
+ if (this.pendingWrite) await this.pendingWrite
835
+ this.controlClosePromise = this.closeServer(this.controlServer)
836
+ for (const socket of this.controlSockets) if (socket !== completionSocket) socket.destroy()
837
+ await Promise.all([
838
+ ...[...this.services.values()].map((processInstance) => processInstance.quiesce()),
839
+ ...[...this.singletons.values()].map((processInstance) => processInstance.quiesce()),
840
+ ...[...this.startingReleases].map((release) => release.quiesce()),
841
+ ...[...this.releases.values()].map((release) => release.quiesce())
842
+ ])
843
+ await this.removeControlSocket()
844
+ void this.closeServer(this.proxyServer)
845
+ void Promise.allSettled([
846
+ ...[...this.services.values()].map((processInstance) => processInstance.stop()),
847
+ ...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
848
+ ...[...this.startingReleases].map((release) => release.stop()),
849
+ ...[...this.releases.values()].map((release) => release.stop())
850
+ ])
851
+ this.logger("external owner retired", {attestation, status: "draining"})
852
+ }
853
+
800
854
  /**
801
855
  * @param {net.Socket | undefined} completionSocket - Requester retained for the final response.
802
856
  * @returns {Promise<void>} Retires listeners and cleans up every daemon-owned resource.
@@ -925,7 +979,7 @@ function isShutdownControlLine(line) {
925
979
  try {
926
980
  const command = JSON.parse(line)
927
981
 
928
- return Boolean(command && typeof command === "object" && command.command === "shutdown")
982
+ return Boolean(command && typeof command === "object" && ["retire-owner", "shutdown"].includes(command.command))
929
983
  } catch {
930
984
  return false
931
985
  }
@@ -62,6 +62,7 @@ export default class ManagedProcess extends EventEmitter {
62
62
  this.memoryWarned = false
63
63
  this.startedAtMs = /** @type {number | undefined} */ (undefined)
64
64
  this.intentionalStop = false
65
+ this.quiescePromise = /** @type {Promise<void> | undefined} */ (undefined)
65
66
  this.restartTimer = undefined
66
67
  this.child = undefined
67
68
  this.exitPromise = undefined
@@ -78,6 +79,7 @@ export default class ManagedProcess extends EventEmitter {
78
79
  if (this.child) return
79
80
 
80
81
  this.intentionalStop = false
82
+ this.quiescePromise = undefined
81
83
  this.exitCode = undefined
82
84
  this.exitSignal = undefined
83
85
  this.state = "starting"
@@ -338,36 +340,22 @@ export default class ManagedProcess extends EventEmitter {
338
340
  * @returns {Promise<void>} Resolves when stopped.
339
341
  */
340
342
  async stop(options = {}) {
341
- this.intentionalStop = true
342
- this.clearMemoryMonitor()
343
-
344
- if (this.restartTimer) {
345
- clearTimeout(this.restartTimer)
346
- this.restartTimer = undefined
347
- }
348
-
349
- const child = this.child
343
+ const pgid = this.child?.pid ?? this.pid
344
+ const exitPromise = this.exitPromise
345
+ await this.quiesce()
350
346
 
351
- if (!child || !child.pid) {
347
+ if (!pgid) {
352
348
  this.state = "stopped"
353
349
  return
354
350
  }
355
351
 
356
- const pgid = child.pid
357
- const exitPromise = this.exitPromise
358
-
359
- this.state = "stopping"
360
-
361
- const {drainCommand, drainTimeoutMs, quietCommand, stopCommand} = this.lifecycle
352
+ const {drainCommand, drainTimeoutMs, stopCommand} = this.lifecycle
362
353
 
363
354
  const hookTimeoutMs = this.hookTimeoutMs()
364
355
 
365
- // 1. Quiesce: tell the process to stop accepting new work.
366
- if (quietCommand) await this.runHook(quietCommand, hookTimeoutMs, "quiet command")
367
-
368
356
  // 2. Drain: let in-flight work finish, bounded by drainTimeoutMs (0 skips the step). A
369
357
  // drainCommand blocks until drained; otherwise wait for the process to exit on its own.
370
- if (this.child && drainTimeoutMs > 0) {
358
+ if (this.processGroupExists(pgid) && drainTimeoutMs > 0) {
371
359
  if (drainCommand) await this.runHook(drainCommand, drainTimeoutMs, "drain command")
372
360
  else await this.waitForExit(drainTimeoutMs)
373
361
  }
@@ -391,6 +379,26 @@ export default class ManagedProcess extends EventEmitter {
391
379
  this.state = "stopped"
392
380
  }
393
381
 
382
+ /** @returns {Promise<void>} Stops restarts and waits until the process has stopped accepting new work. */
383
+ async quiesce() {
384
+ if (this.quiescePromise) return await this.quiescePromise
385
+ this.quiescePromise = (async () => {
386
+ this.intentionalStop = true
387
+ this.clearMemoryMonitor()
388
+ if (this.restartTimer) {
389
+ clearTimeout(this.restartTimer)
390
+ this.restartTimer = undefined
391
+ }
392
+ if (!this.child?.pid) {
393
+ this.state = "stopped"
394
+ return
395
+ }
396
+ this.state = "stopping"
397
+ if (this.lifecycle.quietCommand) await this.runHook(this.lifecycle.quietCommand, this.hookTimeoutMs(), "quiet command")
398
+ })()
399
+ return await this.quiescePromise
400
+ }
401
+
394
402
  /** @returns {number} Timeout used for lifecycle hooks. */
395
403
  hookTimeoutMs() {
396
404
  if (this.stopTimeoutMs === "indefinite") return 30000
@@ -403,6 +403,11 @@ export default class ReleaseGroup extends EventEmitter {
403
403
  this.stoppedAt = new Date().toISOString()
404
404
  }
405
405
 
406
+ /** @returns {Promise<void>} Quiesces every release process without waiting for its drain. */
407
+ async quiesce() {
408
+ await Promise.all([...this.processes.values()].map((processInstance) => processInstance.quiesce()))
409
+ }
410
+
406
411
  /** @returns {ReleaseStatus} Status payload. */
407
412
  status() {
408
413
  return {
@@ -85,6 +85,38 @@ test("daemon bootstrap activates the exact release through the foreground daemon
85
85
  }
86
86
  })
87
87
 
88
+ test("failed takeover bootstrap preserves the previously accepted owner", async () => {
89
+ const fixture = await createFixture()
90
+ const accepted = spawnDaemon(fixture, {attestation: firstAttestation, releaseId: "accepted", revision: "accepted123"})
91
+
92
+ try {
93
+ await waitForLog(accepted, "control socket listening")
94
+ const badConfig = JSON.parse((await fs.readFile(fixture.configPath, "utf8")).replace(/^module\.exports = /, ""))
95
+ badConfig.processes[0].health.path = "/never-ready"
96
+ badConfig.processes[0].health.timeoutMs = 100
97
+ await fs.writeFile(fixture.configPath, `module.exports = ${JSON.stringify(badConfig, null, 2)}\n`)
98
+
99
+ const result = await runDaemon([
100
+ "--config", fixture.configPath,
101
+ "--release-path", fixture.root,
102
+ "--release-id", "candidate",
103
+ "--revision", "candidate123",
104
+ "--boot-attestation", secondAttestation,
105
+ "--takeover-owner"
106
+ ])
107
+
108
+ assert.notEqual(result.code, 0)
109
+ const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
110
+ assert.equal(status.activeReleaseId, "accepted")
111
+ assert.ok(status.bootstrap && typeof status.bootstrap === "object" && !Array.isArray(status.bootstrap))
112
+ assert.equal(status.bootstrap.attestation, firstAttestation)
113
+ } finally {
114
+ accepted.kill("SIGTERM")
115
+ if (accepted.exitCode === null) await once(accepted, "exit")
116
+ await fs.rm(fixture.root, {force: true, recursive: true})
117
+ }
118
+ })
119
+
88
120
  test("daemon bootstrap does not expose control deploys until activation completes", async () => {
89
121
  const fixture = await createFixture({healthGate: true, healthTimeoutMs: 60000})
90
122
  const started = waitForFile(fixture.startedPath)
@@ -855,6 +855,14 @@ test("a process over its memory limit is restarted with reason memory", {skip: p
855
855
  assert.equal(hog.lastStartReason, "memory")
856
856
  assert.equal(typeof hog.lastMemoryRestartAt, "string")
857
857
 
858
+ // Keep the replacement alive long enough to observe its next monitor sample. The fixture
859
+ // remains over the configured limit after every launch, otherwise it can restart again and
860
+ // clear rssBytes/children before this polling loop observes them on slower CI runners.
861
+ const hogProcess = daemon.activeRelease?.processes.get("hog")
862
+
863
+ assert.ok(hogProcess?.memory)
864
+ hogProcess.memory.limitBytes = Number.MAX_SAFE_INTEGER
865
+
858
866
  // rssBytes is sampled on the monitor's interval; wait for a measurement of the running process.
859
867
  await waitFor(() => {
860
868
  const rssBytes = activeProcessStatus(daemon, "hog")?.rssBytes
@@ -115,6 +115,43 @@ test("shutdown response waits for endpoint and owned-process cleanup before imme
115
115
  }
116
116
  })
117
117
 
118
+ test("external-owner retirement releases listeners before a long-draining companion exits", async () => {
119
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-"))
120
+ const socketPath = path.join(root, "rollbridge.sock")
121
+ const gatePath = path.join(root, "retire.fifo")
122
+ const stoppingPath = path.join(root, "stopping")
123
+ const gate = spawn("mkfifo", [gatePath])
124
+ assert.equal((await once(gate, "exit"))[0], 0)
125
+ const config = buildConfig(socketPath, {companion: {
126
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
127
+ id: "worker",
128
+ lifecycle: {drainCommand: `read released < ${JSON.stringify(gatePath)}`, drainTimeoutMs: 60000, quietCommand: `printf stopping > ${JSON.stringify(stoppingPath)}`},
129
+ policy: "companion"
130
+ }})
131
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
132
+ let replacement
133
+
134
+ try {
135
+ await daemon.start()
136
+ await daemon.deploy({releaseId: "old", releasePath: root, revision: "old"})
137
+ const retirement = sendControlCommand({command: {attestation: `sha256:${"a".repeat(64)}`, command: "retire-owner"}, path: socketPath})
138
+ const response = await retirement
139
+ assert.deepEqual(response, {message: "owner retired", status: "success"})
140
+ assert.equal(await fs.readFile(stoppingPath, "utf8"), "stopping", "old worker must stop accepting work before listener takeover")
141
+
142
+ replacement = new RollbridgeDaemon({config, logger: () => {}})
143
+ await replacement.start({reportOrphans: false})
144
+ assert.equal((await sendControlCommand({command: {command: "status"}, path: socketPath})).application, "shutdown-target")
145
+ assert.deepEqual(replacement.status().orphans, [], "intentional retired companions are not replacement orphans")
146
+ assert.equal(daemon.status().releases[0].processes[0].state, "stopping")
147
+ } finally {
148
+ await fs.writeFile(gatePath, "done\n").catch(() => {})
149
+ if (replacement) await replacement.shutdown()
150
+ await daemon.shutdown()
151
+ await fs.rm(root, {force: true, recursive: true})
152
+ }
153
+ })
154
+
118
155
  test("control socket unlink failure is reported only after owned cleanup completes", async () => {
119
156
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-unlink-failure-"))
120
157
  const socketPath = path.join(root, "control.sock")