rollbridge 0.1.18 → 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
@@ -509,6 +518,12 @@ Shut down the daemon and managed processes:
509
518
  rollbridge shutdown --config rollbridge.js
510
519
  ```
511
520
 
521
+ A successful shutdown response is emitted only after the targeted control
522
+ endpoint has stopped accepting connections and been removed, owned processes
523
+ and the proxy have stopped, and persistent state cleanup has finished. It is
524
+ therefore safe to start or ensure a replacement daemon immediately, without a
525
+ delay or retry loop. Cleanup failure or an already-missing daemon exits non-zero.
526
+
512
527
  Prepare a first Rollbridge deploy by recovering Rollbridge-managed orphans and
513
528
  stopping configured legacy processes:
514
529
 
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
  ```
@@ -292,7 +299,15 @@ rollbridge shutdown [--config <path>]
292
299
 
293
300
  Stops all managed processes (services, singletons, and releases), closes the
294
301
  proxy and control socket, removes the socket file, and prints
295
- `{"status": "success", "message": "shutdown"}`.
302
+ `{"status": "success", "message": "shutdown"}`. The success response is a
303
+ completion signal, not an early acknowledgement: before sending it, Rollbridge
304
+ stops accepting new control connections, removes the targeted socket, finishes
305
+ owned-process and proxy cleanup, and finalizes persistent state. A caller may
306
+ immediately start or ensure a replacement daemon after the command returns.
307
+
308
+ If cleanup fails, the command exits non-zero with the daemon's error instead of
309
+ reporting success. Calling `shutdown` when no daemon owns the configured control
310
+ socket also remains an explicit connection error.
296
311
 
297
312
  ## `validate`
298
313
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.18",
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
@@ -56,11 +56,16 @@ export default class RollbridgeDaemon {
56
56
  this.proxy = httpProxy.createProxyServer({ws: true, xfwd: true})
57
57
  this.proxyServer = /** @type {http.Server | undefined} */ (undefined)
58
58
  this.controlServer = /** @type {net.Server | undefined} */ (undefined)
59
+ this.controlSockets = /** @type {Set<net.Socket>} */ (new Set())
59
60
  this.proxyPort = /** @type {number | undefined} */ (undefined)
60
61
  this.stopping = false
61
62
  this.statePath = config.statePath
62
63
  this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
64
+ this.persistenceEnabled = false
63
65
  this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
66
+ this.shutdownPromise = /** @type {Promise<void> | undefined} */ (undefined)
67
+ this.retirementPromise = /** @type {Promise<void> | undefined} */ (undefined)
68
+ this.controlClosePromise = /** @type {Promise<void> | undefined} */ (undefined)
64
69
  this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
65
70
  // Still-alive managed processes left by a previous daemon (from statePath), captured at
66
71
  // startup and surfaced in status(). The daemon cannot re-manage them, only report them.
@@ -71,11 +76,11 @@ export default class RollbridgeDaemon {
71
76
 
72
77
  /**
73
78
  * Starts daemon listeners.
74
- * @param {{exposeControl?: boolean}} [options] - Whether to expose the control socket immediately.
79
+ * @param {{exposeControl?: boolean, reportOrphans?: boolean}} [options] - Listener startup options.
75
80
  * @returns {Promise<void>} Resolves when the requested listeners are ready.
76
81
  */
77
- async start({exposeControl = true} = {}) {
78
- await this.reportOrphans()
82
+ async start({exposeControl = true, reportOrphans = true} = {}) {
83
+ if (reportOrphans) await this.reportOrphans()
79
84
  await this.startProxy()
80
85
  if (exposeControl) await this.exposeControl()
81
86
  }
@@ -247,9 +252,17 @@ export default class RollbridgeDaemon {
247
252
  * @returns {void}
248
253
  */
249
254
  handleControlSocket(socket) {
255
+ this.controlSockets.add(socket)
250
256
  socket.setEncoding("utf8")
251
257
  let buffer = ""
252
258
 
259
+ socket.once("close", () => this.controlSockets.delete(socket))
260
+ socket.on("error", (error) => {
261
+ const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : null
262
+
263
+ this.logger("control connection error", {code, error: error.message})
264
+ })
265
+
253
266
  socket.on("data", (chunk) => {
254
267
  buffer += chunk
255
268
  let newlineIndex = buffer.indexOf("\n")
@@ -269,22 +282,34 @@ export default class RollbridgeDaemon {
269
282
  * @returns {void}
270
283
  */
271
284
  handleControlLine(line, socket) {
272
- this.executeControlLine(line)
273
- .then((response) => socket.write(`${JSON.stringify({status: "success", ...response})}\n`))
285
+ const closesConnection = isShutdownControlLine(line)
286
+ const respond = (/** @type {Record<string, JsonValue>} */ response) => {
287
+ const payload = `${JSON.stringify(response)}\n`
288
+
289
+ if (closesConnection) {
290
+ socket.end(payload, () => socket.destroy())
291
+ } else if (!socket.destroyed) {
292
+ socket.write(payload)
293
+ }
294
+ }
295
+
296
+ this.executeControlLine(line, socket)
297
+ .then((response) => respond({status: "success", ...response}))
274
298
  .catch((error) => {
275
299
  this.logger("command failed", {error: error instanceof Error ? error.message : String(error)})
276
- socket.write(`${JSON.stringify({
300
+ respond({
277
301
  error: error instanceof Error ? error.message : String(error),
278
302
  status: "error"
279
- })}\n`)
303
+ })
280
304
  })
281
305
  }
282
306
 
283
307
  /**
284
308
  * @param {string} line - JSON command line.
309
+ * @param {net.Socket} [controlSocket] - Requesting control connection, used only for shutdown completion.
285
310
  * @returns {Promise<Record<string, JsonValue>>} Command response.
286
311
  */
287
- async executeControlLine(line) {
312
+ async executeControlLine(line, controlSocket) {
288
313
  const command = JSON.parse(line)
289
314
 
290
315
  if (!command || typeof command !== "object") {
@@ -327,15 +352,21 @@ export default class RollbridgeDaemon {
327
352
  }
328
353
 
329
354
  if (commandName === "shutdown") {
330
- setImmediate(() => {
331
- this.shutdown().catch((error) => {
332
- this.logger("shutdown failed", {error: error instanceof Error ? error.message : String(error)})
333
- })
334
- })
355
+ // Stop accepting new control connections before cleanup, but keep this requesting
356
+ // connection open as the completion channel. Waiting for all control connections here
357
+ // would deadlock: server.close() includes the socket awaiting this response.
358
+ await this.shutdown({completionSocket: controlSocket, waitForControlConnections: false})
335
359
 
336
360
  return {message: "shutdown"}
337
361
  }
338
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
+
339
370
  throw new Error(`Unknown command: ${String(commandName)}`)
340
371
  }
341
372
 
@@ -710,6 +741,7 @@ export default class RollbridgeDaemon {
710
741
  startStatePersistence() {
711
742
  if (!this.statePath) return
712
743
 
744
+ this.persistenceEnabled = true
713
745
  this.persistState()
714
746
  this.persistTimer = setInterval(() => this.persistState(), STATE_PERSIST_INTERVAL_MS)
715
747
  this.persistTimer.unref?.()
@@ -721,7 +753,7 @@ export default class RollbridgeDaemon {
721
753
  * @returns {void}
722
754
  */
723
755
  persistState() {
724
- if (!this.statePath || this.stopping) return
756
+ if (!this.statePath || !this.persistenceEnabled || this.stopping) return
725
757
 
726
758
  const statePath = this.statePath
727
759
  const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
@@ -763,30 +795,100 @@ export default class RollbridgeDaemon {
763
795
  }
764
796
  }
765
797
 
766
- /** @returns {Promise<void>} Stops proxy, control socket, and child processes. */
767
- async shutdown() {
768
- if (this.stopping) return
798
+ /**
799
+ * Stops proxy, control socket, and child processes.
800
+ * @param {{completionSocket?: net.Socket, waitForControlConnections?: boolean}} [options] - Shutdown connection behavior.
801
+ * @returns {Promise<void>} Resolves when owned resources are stopped (and, by default, control connections close).
802
+ */
803
+ async shutdown({completionSocket, waitForControlConnections = true} = {}) {
804
+ if (!this.shutdownPromise) this.shutdownPromise = this.performShutdown(completionSocket)
805
+
806
+ await this.shutdownPromise
807
+ if (waitForControlConnections && this.controlClosePromise) await this.controlClosePromise
808
+ }
769
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
+
854
+ /**
855
+ * @param {net.Socket | undefined} completionSocket - Requester retained for the final response.
856
+ * @returns {Promise<void>} Retires listeners and cleans up every daemon-owned resource.
857
+ */
858
+ async performShutdown(completionSocket) {
770
859
  this.stopping = true
860
+ const cleanupErrors = /** @type {Error[]} */ ([])
861
+
862
+ // server.close() stops new connections synchronously. Unlink immediately afterward so a
863
+ // replacement can bind as soon as cleanup completes; existing connections remain usable for
864
+ // the shutdown completion/error response.
865
+ this.controlClosePromise = this.closeServer(this.controlServer)
866
+
867
+ for (const socket of this.controlSockets) {
868
+ if (socket !== completionSocket) socket.destroy()
869
+ }
870
+
871
+ await captureShutdownError(cleanupErrors, "control socket unlink", () => this.removeControlSocket())
771
872
 
772
873
  if (this.persistTimer) {
773
874
  clearInterval(this.persistTimer)
774
875
  this.persistTimer = undefined
775
876
  }
776
877
 
777
- this.proxy.close()
778
- await Promise.allSettled([...this.services.values()].map((processInstance) => processInstance.stop()))
779
- await Promise.allSettled([...this.singletons.values()].map((processInstance) => processInstance.stop()))
780
- await Promise.allSettled([...this.startingReleases].map((release) => release.stop()))
781
- await Promise.allSettled([...this.releases.values()].map((release) => release.stop()))
782
- await this.closeServer(this.proxyServer)
783
- await this.closeServer(this.controlServer)
784
- await fs.rm(this.config.control.path, {force: true})
878
+ await captureShutdownError(cleanupErrors, "proxy close", async () => this.proxy.close())
879
+ const stopResults = await Promise.allSettled([
880
+ ...[...this.services.values()].map((processInstance) => processInstance.stop()),
881
+ ...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
882
+ ...[...this.startingReleases].map((release) => release.stop()),
883
+ ...[...this.releases.values()].map((release) => release.stop())
884
+ ])
885
+ await captureShutdownError(cleanupErrors, "proxy server close", () => this.closeServer(this.proxyServer))
785
886
 
786
887
  // Wait for any in-flight write first so it can't recreate or overwrite the final state (no
787
888
  // new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
788
889
  // orphans are not owned by this daemon, so retain their records until they are confirmed gone.
789
- if (this.statePath) {
890
+ await captureShutdownError(cleanupErrors, "persistent state cleanup", async () => {
891
+ if (!this.statePath) return
790
892
  if (this.pendingWrite) await this.pendingWrite
791
893
  const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
792
894
 
@@ -795,7 +897,20 @@ export default class RollbridgeDaemon {
795
897
  } else {
796
898
  await clearState(this.statePath)
797
899
  }
900
+ })
901
+
902
+ const stopErrors = stopResults.filter((result) => result.status === "rejected").map((result) => result.reason)
903
+
904
+ if (stopErrors.length > 0) {
905
+ cleanupErrors.push(new AggregateError(stopErrors, `Shutdown failed to stop ${stopErrors.length} owned resource${stopErrors.length === 1 ? "" : "s"}.`))
798
906
  }
907
+
908
+ if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, cleanupErrors.map((error) => error.message).join("; "))
909
+ }
910
+
911
+ /** @returns {Promise<void>} Removes the configured control socket path. */
912
+ async removeControlSocket() {
913
+ await fs.rm(this.config.control.path, {force: true})
799
914
  }
800
915
 
801
916
  /**
@@ -856,6 +971,37 @@ function stringOrUndefined(value) {
856
971
  return value
857
972
  }
858
973
 
974
+ /**
975
+ * @param {string} line - Raw control line.
976
+ * @returns {boolean} Whether the line requests shutdown and needs a terminal response connection.
977
+ */
978
+ function isShutdownControlLine(line) {
979
+ try {
980
+ const command = JSON.parse(line)
981
+
982
+ return Boolean(command && typeof command === "object" && ["retire-owner", "shutdown"].includes(command.command))
983
+ } catch {
984
+ return false
985
+ }
986
+ }
987
+
988
+ /**
989
+ * Runs one shutdown cleanup step and records a labeled failure without skipping later cleanup.
990
+ * @param {Error[]} errors - Accumulated cleanup errors.
991
+ * @param {string} label - Non-secret cleanup step name.
992
+ * @param {() => Promise<void>} operation - Cleanup operation.
993
+ * @returns {Promise<void>} Resolves after the operation succeeds or its failure is recorded.
994
+ */
995
+ async function captureShutdownError(errors, label, operation) {
996
+ try {
997
+ await operation()
998
+ } catch (error) {
999
+ const reason = error instanceof Error ? error.message : String(error)
1000
+
1001
+ errors.push(new Error(`${label} failed: ${reason}`, {cause: error}))
1002
+ }
1003
+ }
1004
+
859
1005
  const SECRET_BEARING_STATE_KEYS = new Set(["children", "command", "cwd", "env", "environment", "logs", "output"])
860
1006
 
861
1007
  /**
@@ -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
@@ -0,0 +1,327 @@
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 net from "node:net"
8
+ import os from "node:os"
9
+ import path from "node:path"
10
+ import test from "node:test"
11
+ import {fileURLToPath} from "node:url"
12
+ import {normalizeConfig} from "../src/config.js"
13
+ import {sendControlCommand} from "../src/control-client.js"
14
+ import RollbridgeDaemon from "../src/daemon.js"
15
+ import {isProcessAlive} from "../src/state-store.js"
16
+
17
+ const dummyAppPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "dummy-app.js")
18
+
19
+ test("shutdown response waits for endpoint and owned-process cleanup before immediate replacement", async () => {
20
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-completion-"))
21
+ const socketPath = path.join(root, "control.sock")
22
+ const unrelatedSocketPath = path.join(root, "unrelated.sock")
23
+ const gatePath = path.join(root, "shutdown.fifo")
24
+ const stoppingPath = path.join(root, "stopping")
25
+ const gate = spawn("mkfifo", [gatePath])
26
+
27
+ assert.equal((await once(gate, "exit"))[0], 0)
28
+
29
+ const config = buildConfig(socketPath, {
30
+ companion: {
31
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
32
+ id: "worker",
33
+ lifecycle: {drainTimeoutMs: 0, quietCommand: `printf stopping > ${JSON.stringify(stoppingPath)}; read released < ${JSON.stringify(gatePath)}`},
34
+ policy: "companion"
35
+ }
36
+ })
37
+ const unrelatedConfig = buildConfig(unrelatedSocketPath)
38
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
39
+ const unrelated = new RollbridgeDaemon({config: unrelatedConfig, logger: () => {}})
40
+ let idleTarget = /** @type {net.Socket | undefined} */ (undefined)
41
+ let idleUnrelated = /** @type {net.Socket | undefined} */ (undefined)
42
+ let replacement
43
+ let gateReleased = false
44
+
45
+ try {
46
+ await daemon.start()
47
+ await unrelated.start()
48
+ idleTarget = net.createConnection(socketPath)
49
+ idleUnrelated = net.createConnection(unrelatedSocketPath)
50
+ await Promise.all([once(idleTarget, "connect"), once(idleUnrelated, "connect")])
51
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
52
+
53
+ const workerPid = daemon.activeRelease?.getProcess("worker")?.pid
54
+
55
+ assert.equal(typeof workerPid, "number")
56
+
57
+ const stopping = waitForFile(stoppingPath)
58
+ let shutdownResolved = false
59
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
60
+ .then((response) => {
61
+ shutdownResolved = true
62
+ return response
63
+ })
64
+
65
+ await stopping
66
+
67
+ let oldEndpointAccepted = true
68
+
69
+ try {
70
+ await sendControlCommand({command: {command: "status"}, path: socketPath})
71
+ } catch {
72
+ oldEndpointAccepted = false
73
+ }
74
+
75
+ const resolvedDuringStop = shutdownResolved
76
+ const processAliveDuringStop = isProcessAlive(/** @type {number} */ (workerPid))
77
+ const idleTargetClosedDuringStop = idleTarget.destroyed
78
+ const idleUnrelatedClosedDuringStop = idleUnrelated.destroyed
79
+
80
+ // Ensure the RED path cannot leave an idle client handle blocking test cleanup.
81
+ idleTarget.destroy()
82
+
83
+ await fs.writeFile(gatePath, "continue\n")
84
+ gateReleased = true
85
+
86
+ const response = await shutdown
87
+
88
+ assert.equal(shutdownResolved, true)
89
+ assert.equal(resolvedDuringStop, false, "shutdown must not acknowledge while an owned process is still stopping")
90
+ assert.equal(oldEndpointAccepted, false, "the targeted endpoint must stop accepting new commands before cleanup")
91
+ assert.equal(processAliveDuringStop, true, "the fixture must hold shutdown while its owned process is alive")
92
+ assert.equal(idleTargetClosedDuringStop, true, "an idle accepted client must be closed when the targeted endpoint retires")
93
+ assert.equal(idleUnrelatedClosedDuringStop, false, "an unrelated daemon's accepted clients must remain untouched")
94
+ assert.deepEqual(response, {message: "shutdown", status: "success"})
95
+ await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
96
+ assert.equal(isProcessAlive(/** @type {number} */ (workerPid)), false)
97
+
98
+ // A different daemon remains reachable; shutdown is scoped to the targeted control endpoint.
99
+ assert.equal((await sendControlCommand({command: {command: "status"}, path: unrelatedSocketPath})).application, "shutdown-unrelated")
100
+
101
+ // Replacement starts immediately, with no polling or retry between truthful ACK and bind.
102
+ replacement = new RollbridgeDaemon({config, logger: () => {}})
103
+ await replacement.start()
104
+ assert.equal((await sendControlCommand({command: {command: "status"}, path: socketPath})).application, "shutdown-target")
105
+ } finally {
106
+ if (!gateReleased) {
107
+ await fs.writeFile(gatePath, "continue\n").catch(() => {})
108
+ }
109
+ idleTarget?.destroy()
110
+ idleUnrelated?.destroy()
111
+ if (replacement) await replacement.shutdown()
112
+ await daemon.shutdown()
113
+ await unrelated.shutdown()
114
+ await fs.rm(root, {force: true, recursive: true})
115
+ }
116
+ })
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
+
155
+ test("control socket unlink failure is reported only after owned cleanup completes", async () => {
156
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-unlink-failure-"))
157
+ const socketPath = path.join(root, "control.sock")
158
+ const statePath = path.join(root, "state.json")
159
+ const config = normalizeConfig({...rawConfig(socketPath), statePath})
160
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
161
+
162
+ try {
163
+ await daemon.start()
164
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
165
+ if (daemon.pendingWrite) await daemon.pendingWrite
166
+
167
+ const webPid = daemon.activeRelease?.getProcess("web")?.pid
168
+ const proxyPort = daemon.getProxyPort()
169
+
170
+ assert.equal(typeof webPid, "number")
171
+ assert.equal(typeof proxyPort, "number")
172
+
173
+ daemon.removeControlSocket = async () => { throw new Error("injected unlink failure") }
174
+
175
+ await assert.rejects(
176
+ () => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
177
+ /control socket unlink failed: injected unlink failure/
178
+ )
179
+
180
+ assert.equal(isProcessAlive(/** @type {number} */ (webPid)), false, "unlink failure must not strand an owned process")
181
+ await assert.rejects(() => fetch(`http://127.0.0.1:${proxyPort}/ping`))
182
+ await assert.rejects(() => fs.stat(statePath), {code: "ENOENT"})
183
+ } finally {
184
+ await fs.rm(root, {force: true, recursive: true})
185
+ }
186
+ })
187
+
188
+ test("direct shutdown closes idle accepted clients and converges", async () => {
189
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-direct-shutdown-idle-"))
190
+ const socketPath = path.join(root, "control.sock")
191
+ const daemon = new RollbridgeDaemon({config: buildConfig(socketPath), logger: () => {}})
192
+ let idle = /** @type {net.Socket | undefined} */ (undefined)
193
+
194
+ try {
195
+ await daemon.start()
196
+ idle = net.createConnection(socketPath)
197
+ await once(idle, "connect")
198
+ const idleClosed = once(idle, "close")
199
+
200
+ await daemon.shutdown()
201
+ await idleClosed
202
+
203
+ assert.equal(idle.destroyed, true)
204
+ await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
205
+ } finally {
206
+ idle?.destroy()
207
+ await fs.rm(root, {force: true, recursive: true})
208
+ }
209
+ })
210
+
211
+ test("shutdown reports cleanup failure and still retires the targeted endpoint", async () => {
212
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-failure-"))
213
+ const socketPath = path.join(root, "control.sock")
214
+ const statePath = path.join(root, "state-directory")
215
+
216
+ await fs.mkdir(statePath)
217
+
218
+ const config = normalizeConfig({
219
+ ...rawConfig(socketPath),
220
+ statePath
221
+ })
222
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
223
+
224
+ try {
225
+ await daemon.start()
226
+
227
+ await assert.rejects(
228
+ () => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
229
+ /directory|EISDIR/i
230
+ )
231
+ await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
232
+ } finally {
233
+ await fs.rm(root, {force: true, recursive: true})
234
+ }
235
+ })
236
+
237
+ test("shutdown does not turn an owned-resource stop rejection into success", async () => {
238
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-stop-failure-"))
239
+ const socketPath = path.join(root, "control.sock")
240
+ const config = buildConfig(socketPath)
241
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
242
+ let restoreStop
243
+
244
+ try {
245
+ await daemon.start()
246
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
247
+
248
+ const release = daemon.activeRelease
249
+
250
+ assert.ok(release)
251
+ const originalStop = release.stop.bind(release)
252
+
253
+ restoreStop = originalStop
254
+ release.stop = async () => { throw new Error("owned release stop failed") }
255
+
256
+ await assert.rejects(
257
+ () => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
258
+ /Shutdown failed to stop 1 owned resource/
259
+ )
260
+ } finally {
261
+ if (restoreStop) await restoreStop()
262
+ await fs.rm(root, {force: true, recursive: true})
263
+ }
264
+ })
265
+
266
+ test("shutdown of an already-stopped endpoint fails explicitly", async () => {
267
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-missing-"))
268
+ const socketPath = path.join(root, "missing.sock")
269
+
270
+ try {
271
+ await assert.rejects(
272
+ () => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
273
+ (error) => Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT")
274
+ )
275
+ } finally {
276
+ await fs.rm(root, {force: true, recursive: true})
277
+ }
278
+ })
279
+
280
+ /**
281
+ * @param {string} socketPath - Control socket path.
282
+ * @param {{companion?: Record<string, import("../src/json.js").JsonValue>}} [options] - Optional companion process.
283
+ * @returns {import("../src/config.js").RollbridgeConfig} Normalized config.
284
+ */
285
+ function buildConfig(socketPath, {companion} = {}) {
286
+ return normalizeConfig({
287
+ ...rawConfig(socketPath),
288
+ ...(companion ? {processes: [companion, ...rawConfig(socketPath).processes]} : {})
289
+ })
290
+ }
291
+
292
+ /**
293
+ * @param {string} socketPath - Control socket path.
294
+ * @returns {{application: string, control: {path: string}, processes: Record<string, import("../src/json.js").JsonValue>[], proxy: {forceStopTimeoutMs: number, host: string, port: number}}} Raw config.
295
+ */
296
+ function rawConfig(socketPath) {
297
+ return {
298
+ application: socketPath.endsWith("unrelated.sock") ? "shutdown-unrelated" : "shutdown-target",
299
+ control: {path: socketPath},
300
+ processes: [{
301
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
302
+ health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
303
+ id: "web",
304
+ policy: "proxied",
305
+ port: {from: 0, to: 0}
306
+ }],
307
+ proxy: {forceStopTimeoutMs: 1000, host: "127.0.0.1", port: 0}
308
+ }
309
+ }
310
+
311
+ /**
312
+ * @param {string} filePath - File to await without polling.
313
+ * @returns {Promise<void>} Resolves when the file appears.
314
+ */
315
+ async function waitForFile(filePath) {
316
+ const watcher = fs.watch(path.dirname(filePath))
317
+
318
+ try {
319
+ for await (const event of watcher) {
320
+ if (event.filename === path.basename(filePath)) return
321
+ }
322
+ } finally {
323
+ await watcher.return?.()
324
+ }
325
+
326
+ throw new Error(`Watcher ended before ${filePath} appeared`)
327
+ }