rollbridge 0.1.21 → 0.1.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
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
@@ -65,9 +65,19 @@ export async function runCli(argv) {
65
65
  } else {
66
66
  await daemon.exposeControl()
67
67
  }
68
- } catch {
69
- daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error"})
70
- if (!options.takeoverOwner) await daemon.shutdown()
68
+ } catch (error) {
69
+ const failure = error instanceof Error ? error : String(error)
70
+
71
+ daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error", ...errorLogData(failure)})
72
+
73
+ try {
74
+ await daemon.shutdown()
75
+ } catch (shutdownError) {
76
+ const shutdownFailure = shutdownError instanceof Error ? shutdownError : String(shutdownError)
77
+
78
+ daemon.logger("bootstrap shutdown failed", {releaseId: bootstrap.releaseId, status: "error", ...errorLogData(shutdownFailure)})
79
+ }
80
+
71
81
  process.exitCode = 1
72
82
  return
73
83
  }
@@ -960,3 +970,18 @@ function isMissingDaemonError(error) {
960
970
 
961
971
  return error.code === "ENOENT" || error.code === "ECONNREFUSED"
962
972
  }
973
+
974
+ /**
975
+ * Converts any thrown value into JSON-safe diagnostics without losing an Error stack.
976
+ * @param {Error | string} error - Thrown value.
977
+ * @returns {{error: string, stack: string}} Safe structured log fields.
978
+ */
979
+ function errorLogData(error) {
980
+ if (error instanceof Error) {
981
+ return {error: error.message, stack: error.stack || `${error.name}: ${error.message}`}
982
+ }
983
+
984
+ const message = String(error)
985
+
986
+ return {error: message, stack: message}
987
+ }
package/src/daemon.js CHANGED
@@ -56,6 +56,7 @@ 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.controlSocketOwned = false
59
60
  this.controlSockets = /** @type {Set<net.Socket>} */ (new Set())
60
61
  this.proxyPort = /** @type {number | undefined} */ (undefined)
61
62
  this.stopping = false
@@ -63,6 +64,7 @@ export default class RollbridgeDaemon {
63
64
  this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
64
65
  this.persistenceEnabled = false
65
66
  this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
67
+ this.stateCleanupEnabled = false
66
68
  this.shutdownPromise = /** @type {Promise<void> | undefined} */ (undefined)
67
69
  this.retirementPromise = /** @type {Promise<void> | undefined} */ (undefined)
68
70
  this.controlClosePromise = /** @type {Promise<void> | undefined} */ (undefined)
@@ -128,6 +130,7 @@ export default class RollbridgeDaemon {
128
130
  await new Promise((resolve, reject) => {
129
131
  server.once("error", reject)
130
132
  server.listen(this.config.control.path, () => {
133
+ this.controlSocketOwned = true
131
134
  this.logger("control socket listening", {path: this.config.control.path})
132
135
  resolve(undefined)
133
136
  })
@@ -741,6 +744,7 @@ export default class RollbridgeDaemon {
741
744
  startStatePersistence() {
742
745
  if (!this.statePath) return
743
746
 
747
+ this.stateCleanupEnabled = true
744
748
  this.persistenceEnabled = true
745
749
  this.persistState()
746
750
  this.persistTimer = setInterval(() => this.persistState(), STATE_PERSIST_INTERVAL_MS)
@@ -780,6 +784,7 @@ export default class RollbridgeDaemon {
780
784
  async reportOrphans() {
781
785
  if (!this.statePath) return
782
786
 
787
+ this.stateCleanupEnabled = true
783
788
  const orphans = liveProcesses(await readState(this.statePath))
784
789
 
785
790
  // Keep them for status() so `rollbridge status` reflects still-running children after a
@@ -832,6 +837,7 @@ export default class RollbridgeDaemon {
832
837
  }
833
838
  this.persistenceEnabled = false
834
839
  if (this.pendingWrite) await this.pendingWrite
840
+ this.stateCleanupEnabled = false
835
841
  this.controlClosePromise = this.closeServer(this.controlServer)
836
842
  for (const socket of this.controlSockets) if (socket !== completionSocket) socket.destroy()
837
843
  await Promise.all([
@@ -888,7 +894,7 @@ export default class RollbridgeDaemon {
888
894
  // new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
889
895
  // orphans are not owned by this daemon, so retain their records until they are confirmed gone.
890
896
  await captureShutdownError(cleanupErrors, "persistent state cleanup", async () => {
891
- if (!this.statePath) return
897
+ if (!this.statePath || !this.stateCleanupEnabled) return
892
898
  if (this.pendingWrite) await this.pendingWrite
893
899
  const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
894
900
 
@@ -910,7 +916,10 @@ export default class RollbridgeDaemon {
910
916
 
911
917
  /** @returns {Promise<void>} Removes the configured control socket path. */
912
918
  async removeControlSocket() {
919
+ if (!this.controlSocketOwned) return
920
+
913
921
  await fs.rm(this.config.control.path, {force: true})
922
+ this.controlSocketOwned = false
914
923
  }
915
924
 
916
925
  /**
@@ -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.intentionalStopSignal = /** @type {ProcessExitSignal | undefined} */ (undefined)
65
66
  this.quiescePromise = /** @type {Promise<void> | undefined} */ (undefined)
66
67
  this.restartTimer = undefined
67
68
  this.child = undefined
@@ -79,6 +80,7 @@ export default class ManagedProcess extends EventEmitter {
79
80
  if (this.child) return
80
81
 
81
82
  this.intentionalStop = false
83
+ this.intentionalStopSignal = undefined
82
84
  this.quiescePromise = undefined
83
85
  this.exitCode = undefined
84
86
  this.exitSignal = undefined
@@ -167,8 +169,8 @@ export default class ManagedProcess extends EventEmitter {
167
169
  onExit(code, signal) {
168
170
  const wasIntentional = this.intentionalStop
169
171
 
170
- this.exitCode = code
171
- this.exitSignal = signal
172
+ this.exitCode = this.intentionalStopSignal ? null : code
173
+ this.exitSignal = signal ?? this.intentionalStopSignal
172
174
  this.child = undefined
173
175
  this.pid = undefined
174
176
  this.exitPromise = undefined
@@ -362,15 +364,24 @@ export default class ManagedProcess extends EventEmitter {
362
364
 
363
365
  // 3. Stop whatever is still running, then SIGKILL if it outlasts the graceful window.
364
366
  if (this.processGroupExists(pgid)) {
365
- if (stopCommand) await this.runHook(stopCommand, hookTimeoutMs, "stop command", pgid)
366
- else this.killProcessGroup(this.stopSignal, pgid)
367
-
368
367
  const timeoutMs = options.timeoutMs ?? this.stopTimeoutMs
368
+ let gracefulDeadline
369
+
370
+ if (stopCommand) {
371
+ await this.runHook(stopCommand, hookTimeoutMs, "stop command", pgid)
372
+ gracefulDeadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
373
+ } else {
374
+ this.intentionalStopSignal = /** @type {ProcessExitSignal} */ (this.stopSignal)
375
+ gracefulDeadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
376
+ await this.signalProcessGroup(this.stopSignal, pgid, gracefulDeadline)
377
+ }
369
378
 
370
- if (!(await this.waitForProcessGroupExit(pgid, timeoutMs))) {
379
+ if (!(await this.waitForProcessGroupExit(pgid, gracefulDeadline))) {
371
380
  this.logger("process stop timed out; sending SIGKILL", {id: this.id, pid: pgid})
372
- this.killProcessGroup("SIGKILL", pgid)
373
- await this.waitForProcessGroupExit(pgid, 5000)
381
+ const killDeadline = Date.now() + 5000
382
+
383
+ await this.signalProcessGroup("SIGKILL", pgid, killDeadline)
384
+ await this.waitForProcessGroupExit(pgid, killDeadline)
374
385
  }
375
386
  }
376
387
 
@@ -489,6 +500,49 @@ export default class ManagedProcess extends EventEmitter {
489
500
  }
490
501
  }
491
502
 
503
+ /**
504
+ * Signals descendants before their shell leader so the leader can reap them before it exits.
505
+ * Falls back to the portable group signal when procfs cannot identify group members.
506
+ * @param {string} signal - Signal name.
507
+ * @param {number} pgid - Process group id.
508
+ * @param {number | undefined} deadline - Absolute graceful deadline, or undefined to wait indefinitely.
509
+ * @returns {Promise<void>} Resolves after the leader has been signalled.
510
+ */
511
+ async signalProcessGroup(signal, pgid, deadline) {
512
+ const members = processGroupMembers(pgid)
513
+ const descendants = members.filter((member) => member.pid !== pgid)
514
+
515
+ if (descendants.length === 0) {
516
+ this.killProcessGroup(signal, pgid)
517
+ return
518
+ }
519
+
520
+ for (const descendant of descendants) this.killProcess(descendant.pid, signal)
521
+ while (processGroupMembers(pgid).some((member) => member.pid !== pgid)) {
522
+ if (deadline !== undefined && Date.now() >= deadline) break
523
+ const waitMs = deadline === undefined ? 25 : Math.min(25, deadline - Date.now())
524
+
525
+ await new Promise((resolve) => setTimeout(resolve, waitMs))
526
+ }
527
+
528
+ this.killProcess(pgid, signal)
529
+ }
530
+
531
+ /**
532
+ * Signals one verified member of the owned process group.
533
+ * @param {number} pid - Process id.
534
+ * @param {string} signal - Signal name.
535
+ * @returns {void}
536
+ */
537
+ killProcess(pid, signal) {
538
+ try {
539
+ process.kill(pid, signal)
540
+ } catch (error) {
541
+ if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return
542
+ throw error
543
+ }
544
+ }
545
+
492
546
  /**
493
547
  * @param {number} pgid - Process group id.
494
548
  * @returns {boolean} True until the process group no longer exists.
@@ -510,15 +564,15 @@ export default class ManagedProcess extends EventEmitter {
510
564
 
511
565
  /**
512
566
  * @param {number} pgid - Process group id.
513
- * @param {StopTimeoutMs} timeoutMs - Timeout.
567
+ * @param {number | undefined} deadline - Absolute deadline, or undefined to wait indefinitely.
514
568
  * @returns {Promise<boolean>} True once the process group no longer exists.
515
569
  */
516
- async waitForProcessGroupExit(pgid, timeoutMs) {
517
- const deadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
518
-
570
+ async waitForProcessGroupExit(pgid, deadline) {
519
571
  while (this.processGroupExists(pgid)) {
520
572
  if (deadline !== undefined && Date.now() >= deadline) return false
521
- await new Promise((resolve) => setTimeout(resolve, 10))
573
+ const waitMs = deadline === undefined ? 10 : Math.min(10, deadline - Date.now())
574
+
575
+ await new Promise((resolve) => setTimeout(resolve, waitMs))
522
576
  }
523
577
 
524
578
  return true
@@ -4,6 +4,7 @@ import assert from "node:assert/strict"
4
4
  import {spawn} from "node:child_process"
5
5
  import {once} from "node:events"
6
6
  import fs from "node:fs/promises"
7
+ import net from "node:net"
7
8
  import os from "node:os"
8
9
  import path from "node:path"
9
10
  import test from "node:test"
@@ -14,9 +15,12 @@ import {isProcessAlive, liveProcesses, readState, writeState} from "../src/state
14
15
  const currentDir = path.dirname(fileURLToPath(import.meta.url))
15
16
  const binPath = path.join(currentDir, "..", "bin", "rollbridge")
16
17
  const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
18
+ const ownedChildPath = path.join(currentDir, "fixtures", "owned-child.js")
17
19
  const firstAttestation = `sha256:${"a".repeat(64)}`
18
20
  const secondAttestation = `sha256:${"b".repeat(64)}`
19
21
 
22
+ /** @typedef {{data?: Record<string, import("../src/json.js").JsonValue>, message?: string}} StructuredRecord */
23
+
20
24
  test("daemon bootstrap requires complete, safe, absolute inputs before binding listeners", async (t) => {
21
25
  const cases = [
22
26
  {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1"], message: /must be provided together/},
@@ -86,11 +90,12 @@ test("daemon bootstrap activates the exact release through the foreground daemon
86
90
  })
87
91
 
88
92
  test("failed takeover bootstrap preserves the previously accepted owner", async () => {
89
- const fixture = await createFixture()
93
+ const fixture = await createFixture({persistState: true})
90
94
  const accepted = spawnDaemon(fixture, {attestation: firstAttestation, releaseId: "accepted", revision: "accepted123"})
91
95
 
92
96
  try {
93
97
  await waitForLog(accepted, "control socket listening")
98
+ await waitForFile(fixture.statePath)
94
99
  const badConfig = JSON.parse((await fs.readFile(fixture.configPath, "utf8")).replace(/^module\.exports = /, ""))
95
100
  badConfig.processes[0].health.path = "/never-ready"
96
101
  badConfig.processes[0].health.timeoutMs = 100
@@ -106,10 +111,20 @@ test("failed takeover bootstrap preserves the previously accepted owner", async
106
111
  ])
107
112
 
108
113
  assert.notEqual(result.code, 0)
114
+ const records = parseStructuredOutput(result.output)
115
+ const failure = records.find((record) => record.message === "bootstrap activation failed")
116
+ const candidatePid = Number(await fs.readFile(fixture.startedPath, "utf8"))
117
+
118
+ assert.match(String(failure?.data?.error), /Health check failed/)
119
+ assert.match(String(failure?.data?.stack), /Error: Health check failed/)
120
+ assert.equal(isProcessAlive(candidatePid), false, "the failed candidate process must be stopped")
109
121
  const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
110
122
  assert.equal(status.activeReleaseId, "accepted")
111
123
  assert.ok(status.bootstrap && typeof status.bootstrap === "object" && !Array.isArray(status.bootstrap))
112
124
  assert.equal(status.bootstrap.attestation, firstAttestation)
125
+ const priorState = await readState(fixture.statePath)
126
+
127
+ assert.ok(priorState && typeof priorState === "object" && !Array.isArray(priorState) && priorState.activeReleaseId === "accepted", "candidate cleanup must preserve the prior owner's state")
113
128
  } finally {
114
129
  accepted.kill("SIGTERM")
115
130
  if (accepted.exitCode === null) await once(accepted, "exit")
@@ -267,24 +282,56 @@ test("SIGTERM during multi-process bootstrap owns every process started after sh
267
282
  }
268
283
  })
269
284
 
270
- test("failed daemon bootstrap reports a structured failure, cleans its processes, and exits non-zero", async () => {
271
- const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 100})
285
+ test("failed ordinary bootstrap completely shuts down attempt-owned resources and exposes the cause", async () => {
286
+ const fixture = await createFixture({attemptOwnedProcesses: true, fixedPorts: true, missingControlParent: true})
272
287
 
273
288
  try {
274
289
  const result = await runDaemon([
275
290
  "--config", fixture.configPath,
276
291
  "--release-path", fixture.root,
277
- "--release-id", "bad-release",
278
- "--revision", "bad123"
292
+ "--release-id", "ordinary-failure",
293
+ "--revision", "ordinary123"
279
294
  ])
280
- const records = result.output.split("\n").filter(Boolean).map((line) => JSON.parse(line))
295
+ const records = parseStructuredOutput(result.output)
296
+ const failure = records.find((record) => record.message === "bootstrap activation failed")
297
+
298
+ assert.notEqual(result.code, 0)
299
+ assert.equal(failure?.data?.releaseId, "ordinary-failure")
300
+ assert.equal(failure?.data?.status, "error")
301
+ assert.match(String(failure?.data?.error), /listen (?:EACCES|ENOENT)/)
302
+ assert.match(String(failure?.data?.stack), /Error: listen (?:EACCES|ENOENT)/)
303
+ await assertAttemptResourcesStopped(fixture, records)
304
+ await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
305
+ } finally {
306
+ await killAttemptProcesses(fixture.lifecyclePath)
307
+ await fs.rm(fixture.root, {force: true, recursive: true})
308
+ }
309
+ })
310
+
311
+ test("failed takeover retirement completely shuts down and exits non-zero instead of lingering", async () => {
312
+ const fixture = await createFixture({attemptOwnedProcesses: true, fixedPorts: true})
313
+
314
+ try {
315
+ const result = await runDaemon([
316
+ "--config", fixture.configPath,
317
+ "--release-path", fixture.root,
318
+ "--release-id", "orphaned-candidate",
319
+ "--revision", "candidate123",
320
+ "--boot-attestation", secondAttestation,
321
+ "--takeover-owner"
322
+ ], {timeoutMs: 2000})
323
+ const records = parseStructuredOutput(result.output)
281
324
  const failure = records.find((record) => record.message === "bootstrap activation failed")
282
325
 
283
326
  assert.notEqual(result.code, 0)
284
- assert.deepEqual(failure?.data, {releaseId: "bad-release", status: "error"})
285
- assert.ok(records.some((record) => record.message === "release startup process status" && record.data?.phase === "after cleanup" && record.data?.state === "stopped"))
327
+ assert.equal(failure?.data?.releaseId, "orphaned-candidate")
328
+ assert.equal(failure?.data?.status, "error")
329
+ assert.match(String(failure?.data?.error), /connect ENOENT/)
330
+ assert.match(String(failure?.data?.stack), /Error: connect ENOENT/)
331
+ await assertAttemptResourcesStopped(fixture, records)
286
332
  await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
287
333
  } finally {
334
+ await killAttemptProcesses(fixture.lifecyclePath)
288
335
  await fs.rm(fixture.root, {force: true, recursive: true})
289
336
  }
290
337
  })
@@ -350,12 +397,12 @@ test("failed daemon bootstrap preserves prior live process records in statePath"
350
397
  })
351
398
 
352
399
  /**
353
- * @param {{healthGate?: boolean, healthPath?: string, healthTimeoutMs?: number, multiProcessSignal?: boolean, persistState?: boolean}} [options] - Fixture options.
354
- * @returns {Promise<{configPath: string, gatePath: string, healthGatePath: string, lifecyclePath: string, root: string, socketPath: string, startedPath: string, statePath: string, stoppedPath: string}>} Fixture paths.
400
+ * @param {{attemptOwnedProcesses?: boolean, fixedPorts?: boolean, healthGate?: boolean, healthPath?: string, healthTimeoutMs?: number, missingControlParent?: boolean, multiProcessSignal?: boolean, persistState?: boolean}} [options] - Fixture options.
401
+ * @returns {Promise<{configPath: string, gatePath: string, healthGatePath: string, lifecyclePath: string, processPort: number, proxyPort: number, root: string, socketPath: string, startedPath: string, statePath: string, stoppedPath: string}>} Fixture paths.
355
402
  */
356
- async function createFixture({healthGate = false, healthPath = "/ping", healthTimeoutMs = 1000, multiProcessSignal = false, persistState = false} = {}) {
403
+ async function createFixture({attemptOwnedProcesses = false, fixedPorts = false, healthGate = false, healthPath = "/ping", healthTimeoutMs = 1000, missingControlParent = false, multiProcessSignal = false, persistState = false} = {}) {
357
404
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-bootstrap-"))
358
- const socketPath = path.join(root, "control.sock")
405
+ const socketPath = missingControlParent ? path.join(root, "missing", "control.sock") : path.join(root, "control.sock")
359
406
  const statePath = path.join(root, "state.json")
360
407
  const startedPath = path.join(root, "started.pid")
361
408
  const stoppedPath = path.join(root, "stopped.pid")
@@ -364,8 +411,12 @@ async function createFixture({healthGate = false, healthPath = "/ping", healthTi
364
411
  const healthGatePath = path.join(root, "health-ready")
365
412
  const configPath = path.join(root, "rollbridge.js")
366
413
  const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`
414
+ const ownedCommand = `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(ownedChildPath)}`
415
+ const ownedWebCommand = `exec ${command}`
416
+ const [processPort, proxyPort] = fixedPorts ? await availablePorts(2) : [0, 0]
367
417
  const lifecycleEnv = {ROLLBRIDGE_TEST_LIFECYCLE_PATH: lifecyclePath}
368
418
  const webEnv = {
419
+ ...(attemptOwnedProcesses ? lifecycleEnv : {}),
369
420
  ROLLBRIDGE_TEST_STARTED_PATH: startedPath,
370
421
  ROLLBRIDGE_TEST_STOPPED_PATH: stoppedPath,
371
422
  ...(healthGate ? {ROLLBRIDGE_TEST_HEALTH_GATE_PATH: healthGatePath} : {})
@@ -377,8 +428,13 @@ async function createFixture({healthGate = false, healthPath = "/ping", healthTi
377
428
  {command: `trap '' TERM; printf '%s\\n' '{"event":"shutdown"}' >> ${JSON.stringify(lifecyclePath)}; kill -TERM "$ROLLBRIDGE_TEST_DAEMON_PID"; printf '{"event":"started","pid":%s,"processId":"database","replicaIndex":"0"}\\n' "$$" >> ${JSON.stringify(lifecyclePath)}; read ignored < ${JSON.stringify(gatePath)}`, env: lifecycleEnv, id: "database", policy: "service"},
378
429
  {command: `exec ${command}`, env: lifecycleEnv, id: "worker", policy: "companion", replicas: 2},
379
430
  {command: `exec ${command}`, env: lifecycleEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}
431
+ ] : attemptOwnedProcesses ? [
432
+ {command: ownedCommand, env: lifecycleEnv, id: "database", policy: "service"},
433
+ {command: ownedCommand, env: lifecycleEnv, id: "worker", policy: "companion"},
434
+ {command: ownedCommand, env: lifecycleEnv, id: "scheduler", policy: "singleton"},
435
+ {command: ownedWebCommand, env: webEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: processPort, to: processPort}}
380
436
  ] : [{command, env: webEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}],
381
- proxy: {host: "127.0.0.1", port: 0},
437
+ proxy: {host: "127.0.0.1", port: proxyPort},
382
438
  ...(persistState ? {statePath} : {})
383
439
  }
384
440
 
@@ -392,7 +448,7 @@ async function createFixture({healthGate = false, healthPath = "/ping", healthTi
392
448
  }
393
449
 
394
450
  await fs.writeFile(configPath, `${setup}module.exports = ${JSON.stringify(config, null, 2)}\n`)
395
- return {configPath, gatePath, healthGatePath, lifecyclePath, root, socketPath, startedPath, statePath, stoppedPath}
451
+ return {configPath, gatePath, healthGatePath, lifecyclePath, processPort, proxyPort, root, socketPath, startedPath, statePath, stoppedPath}
396
452
  }
397
453
 
398
454
  /**
@@ -427,6 +483,12 @@ async function waitForFile(filePath) {
427
483
  const watcher = fs.watch(path.dirname(filePath))
428
484
 
429
485
  try {
486
+ try {
487
+ return await fs.readFile(filePath, "utf8")
488
+ } catch (error) {
489
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
490
+ }
491
+
430
492
  for await (const event of watcher) {
431
493
  if (event.filename === path.basename(filePath)) return await fs.readFile(filePath, "utf8")
432
494
  }
@@ -452,26 +514,143 @@ function spawnDaemon(fixture, release) {
452
514
 
453
515
  /**
454
516
  * @param {string[]} args - Daemon arguments.
517
+ * @param {{timeoutMs?: number}} [options] - Process execution options.
455
518
  * @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
456
519
  */
457
- async function runDaemon(args) {
458
- return await runRollbridge(["daemon", ...args])
520
+ async function runDaemon(args, options) {
521
+ return await runRollbridge(["daemon", ...args], options)
459
522
  }
460
523
 
461
524
  /**
462
525
  * @param {string[]} args - Rollbridge command and arguments.
526
+ * @param {{timeoutMs?: number}} [options] - Process execution options.
463
527
  * @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
464
528
  */
465
- async function runRollbridge(args) {
529
+ async function runRollbridge(args, {timeoutMs} = {}) {
466
530
  const child = spawn(process.execPath, [binPath, ...args], {stdio: ["ignore", "pipe", "pipe"]})
467
531
  let output = ""
468
532
  let stderr = ""
469
533
 
470
534
  child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
471
535
  child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk })
472
- const [code] = await once(child, "exit")
536
+ let timer
537
+
538
+ const exited = once(child, "exit")
539
+ const result = timeoutMs === undefined ? await exited : await Promise.race([
540
+ exited,
541
+ new Promise((resolve) => {
542
+ timer = setTimeout(() => {
543
+ child.kill("SIGKILL")
544
+ resolve(["timeout"])
545
+ }, timeoutMs)
546
+ })
547
+ ])
548
+
549
+ if (timer) clearTimeout(timer)
550
+ if (result[0] === "timeout") {
551
+ await exited
552
+ throw new Error(`Rollbridge did not terminate within ${timeoutMs}ms`)
553
+ }
554
+
555
+ const [code] = result
556
+
557
+ return {code: typeof code === "number" || code === null ? code : null, output, stderr}
558
+ }
559
+
560
+ /**
561
+ * @param {string} output - JSON-lines daemon output.
562
+ * @returns {StructuredRecord[]} Parsed records.
563
+ */
564
+ function parseStructuredOutput(output) {
565
+ return output.split("\n").filter(Boolean).map((line) => JSON.parse(line))
566
+ }
567
+
568
+ /**
569
+ * @param {{lifecyclePath: string, processPort: number, proxyPort: number}} fixture - Attempt fixture.
570
+ * @param {StructuredRecord[]} records - Structured daemon records.
571
+ * @returns {Promise<void>} Resolves after all owned resources are verified stopped.
572
+ */
573
+ async function assertAttemptResourcesStopped(fixture, records) {
574
+ const events = await readLifecycleEvents(fixture.lifecyclePath)
575
+ const started = events.filter((event) => event.event === "started")
576
+ const stoppedPids = new Set(events.filter((event) => event.event === "stopped").map((event) => event.pid))
577
+ const expectedProcessIds = new Set(["database", "scheduler", "web", "worker"])
578
+ const managedStarts = new Set(records.filter((record) => record.message === "process started").map((record) => record.data?.processId))
579
+ const managedExits = new Set(records.filter((record) => record.message === "process exited").map((record) => record.data?.processId))
580
+
581
+ assert.deepEqual(managedStarts, expectedProcessIds)
582
+ assert.deepEqual(managedExits, expectedProcessIds)
583
+ for (const event of started) {
584
+ assert.equal(stoppedPids.has(event.pid), true, `${event.processId} must receive graceful shutdown`)
585
+ assert.equal(isProcessAlive(Number(event.pid)), false, `${event.processId} pid ${event.pid} must be gone before daemon exit`)
586
+ }
587
+
588
+ await assertPortAvailable(fixture.processPort)
589
+ await assertPortAvailable(fixture.proxyPort)
590
+ }
591
+
592
+ /**
593
+ * @param {string} lifecyclePath - Fixture lifecycle log.
594
+ * @returns {Promise<Record<string, import("../src/json.js").JsonValue>[]>} Parsed lifecycle events.
595
+ */
596
+ async function readLifecycleEvents(lifecyclePath) {
597
+ return (await fs.readFile(lifecyclePath, "utf8")).trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
598
+ }
599
+
600
+ /**
601
+ * Best-effort cleanup for a deliberately failing lingering-process regression.
602
+ * @param {string} lifecyclePath - Fixture lifecycle log.
603
+ * @returns {Promise<void>} Resolves after known fixture process groups are stopped.
604
+ */
605
+ async function killAttemptProcesses(lifecyclePath) {
606
+ try {
607
+ const events = await readLifecycleEvents(lifecyclePath)
608
+
609
+ for (const event of events) {
610
+ const pid = Number(event.pid)
611
+
612
+ if (event.event === "started" && isProcessAlive(pid)) process.kill(-pid, "SIGKILL")
613
+ }
614
+ } catch {
615
+ // The attempt can fail before creating the lifecycle file.
616
+ }
617
+ }
618
+
619
+ /**
620
+ * @param {number} port - Local port expected to be free.
621
+ * @returns {Promise<void>} Resolves after binding and releasing the port.
622
+ */
623
+ async function assertPortAvailable(port) {
624
+ const server = net.createServer()
625
+
626
+ await new Promise((resolve, reject) => {
627
+ server.once("error", reject)
628
+ server.listen(port, "127.0.0.1", () => resolve(undefined))
629
+ })
630
+ await new Promise((resolve) => server.close(() => resolve(undefined)))
631
+ }
632
+
633
+ /**
634
+ * Reserves distinct ephemeral ports until all have been observed, then releases them.
635
+ * @param {number} count - Number of ports.
636
+ * @returns {Promise<number[]>} Available port numbers.
637
+ */
638
+ async function availablePorts(count) {
639
+ const servers = Array.from({length: count}, () => net.createServer())
640
+
641
+ await Promise.all(servers.map((server) => new Promise((resolve, reject) => {
642
+ server.once("error", reject)
643
+ server.listen(0, "127.0.0.1", () => resolve(undefined))
644
+ })))
645
+ const ports = servers.map((server) => {
646
+ const address = server.address()
647
+
648
+ if (!address || typeof address === "string") throw new Error("Expected a TCP server address")
649
+ return address.port
650
+ })
473
651
 
474
- return {code, output, stderr}
652
+ await Promise.all(servers.map((server) => new Promise((resolve) => server.close(() => resolve(undefined)))))
653
+ return ports
475
654
  }
476
655
 
477
656
  /**
@@ -0,0 +1,19 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs"
4
+
5
+ const lifecyclePath = process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH
6
+
7
+ if (!lifecyclePath) throw new Error("ROLLBRIDGE_TEST_LIFECYCLE_PATH is required")
8
+
9
+ /** @param {string} event - Lifecycle event. */
10
+ const record = (event) => {
11
+ fs.appendFileSync(lifecyclePath, `${JSON.stringify({event, pid: process.pid, processId: process.env.ROLLBRIDGE_PROCESS_ID})}\n`)
12
+ }
13
+
14
+ record("started")
15
+ process.on("SIGTERM", () => {
16
+ record("stopped")
17
+ process.exit(0)
18
+ })
19
+ setInterval(() => {}, 1000)
@@ -386,8 +386,10 @@ test("a hanging lifecycle hook is bounded so stop still completes", async () =>
386
386
  })
387
387
 
388
388
  test("sends the configured stopSignal as the graceful stop signal", async () => {
389
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rollbridge-stop-signal-"))
390
+ const readyPath = path.join(dir, "ready")
389
391
  const managed = new ManagedProcess({
390
- command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
392
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`require("node:fs").writeFileSync(${JSON.stringify(readyPath)}, "ready"); setInterval(() => {}, 1000)`)}`,
391
393
  cwd: undefined,
392
394
  env: {},
393
395
  id: "worker",
@@ -401,19 +403,25 @@ test("sends the configured stopSignal as the graceful stop signal", async () =>
401
403
 
402
404
  /** @type {string[]} */
403
405
  const signals = []
404
- const killProcessGroup = managed.killProcessGroup.bind(managed)
406
+ const killProcess = managed.killProcess.bind(managed)
405
407
 
406
- managed.killProcessGroup = (signal) => {
408
+ managed.killProcess = (pid, signal) => {
407
409
  signals.push(signal)
408
- killProcessGroup(signal)
410
+ killProcess(pid, signal)
409
411
  }
410
412
 
411
- await managed.start()
412
- await managed.stop()
413
+ try {
414
+ await managed.start()
415
+ await waitFor(() => fs.existsSync(readyPath))
416
+ await managed.stop()
413
417
 
414
- // The graceful stop uses the configured signal (a SIGKILL fallback, if any, comes after).
415
- assert.equal(signals[0], "SIGINT")
416
- assert.equal(managed.status().state, "stopped")
418
+ // The graceful stop reaches the ready descendant and its shell leader without SIGKILL.
419
+ assert.deepEqual(signals, ["SIGINT", "SIGINT"])
420
+ assert.equal(managed.status().state, "stopped")
421
+ } finally {
422
+ await managed.stop()
423
+ fs.rmSync(dir, {force: true, recursive: true})
424
+ }
417
425
  })
418
426
 
419
427
  test("indefinite stop waits for the process to exit without SIGKILL", async () => {
@@ -431,11 +439,11 @@ test("indefinite stop waits for the process to exit without SIGKILL", async () =
431
439
  })
432
440
  /** @type {string[]} */
433
441
  const signals = []
434
- const killProcessGroup = managed.killProcessGroup.bind(managed)
442
+ const killProcess = managed.killProcess.bind(managed)
435
443
 
436
- managed.killProcessGroup = (signal) => {
444
+ managed.killProcess = (pid, signal) => {
437
445
  signals.push(signal)
438
- killProcessGroup(signal)
446
+ killProcess(pid, signal)
439
447
  }
440
448
 
441
449
  try {
@@ -443,7 +451,7 @@ test("indefinite stop waits for the process to exit without SIGKILL", async () =
443
451
  await managed.stop()
444
452
 
445
453
  assert.equal(managed.status().state, "stopped")
446
- assert.deepEqual(signals, ["SIGTERM"])
454
+ assert.deepEqual(signals, ["SIGTERM", "SIGTERM"])
447
455
  } finally {
448
456
  await managed.stop()
449
457
  }
@@ -491,6 +499,75 @@ test("stop waits for process group descendants after the detached shell exits",
491
499
  }
492
500
  })
493
501
 
502
+ test("stop does not return while a gracefully stopped descendant remains unreaped", async () => {
503
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rollbridge-process-group-"))
504
+ const pidPath = path.join(dir, "child.pid")
505
+ const child = [
506
+ "const fs = require('node:fs')",
507
+ `fs.writeFileSync(${JSON.stringify(pidPath)}, String(process.pid))`,
508
+ "process.on('SIGTERM', () => setTimeout(() => process.exit(0), 50))",
509
+ "setInterval(() => {}, 1000)"
510
+ ].join("; ")
511
+ const managed = new ManagedProcess({
512
+ command: `trap 'exit 0' TERM; ${JSON.stringify(process.execPath)} -e ${JSON.stringify(child)} & wait`,
513
+ cwd: undefined,
514
+ env: {},
515
+ id: "worker",
516
+ logger: () => {},
517
+ outputLines: 50,
518
+ restartDelayMs: 10,
519
+ shouldRestart: () => false,
520
+ stopSignal: "SIGTERM",
521
+ stopTimeoutMs: 2000
522
+ })
523
+
524
+ try {
525
+ await managed.start()
526
+ await waitFor(() => fs.existsSync(pidPath))
527
+ const childPid = Number(fs.readFileSync(pidPath, "utf8"))
528
+
529
+ await managed.stop()
530
+
531
+ assert.throws(() => process.kill(childPid, 0), {code: "ESRCH"})
532
+ } finally {
533
+ await managed.stop()
534
+ fs.rmSync(dir, {force: true, recursive: true})
535
+ }
536
+ })
537
+
538
+ test("descendant reaping and leader shutdown share one graceful deadline", async () => {
539
+ const managed = buildProcess(50)
540
+ const originalNow = Date.now
541
+ let now = 1000
542
+ /** @type {{deadline: number | undefined, signal?: string}[]} */
543
+ const calls = []
544
+
545
+ managed.pid = 123
546
+ managed.exitPromise = Promise.resolve()
547
+ managed.quiesce = async () => {}
548
+ managed.processGroupExists = () => true
549
+ managed.signalProcessGroup = async (signal, _pgid, deadline) => {
550
+ calls.push({deadline, signal})
551
+ now += signal === "SIGTERM" ? 100 : 0
552
+ }
553
+ managed.waitForProcessGroupExit = async (_pgid, deadline) => {
554
+ calls.push({deadline})
555
+ return calls.length > 2
556
+ }
557
+ Date.now = () => now
558
+
559
+ try {
560
+ await managed.stop({timeoutMs: 150})
561
+
562
+ assert.deepEqual(calls.slice(0, 2), [
563
+ {deadline: 1150, signal: "SIGTERM"},
564
+ {deadline: 1150}
565
+ ])
566
+ } finally {
567
+ Date.now = originalNow
568
+ }
569
+ })
570
+
494
571
  test("a memory restart respawns and is counted when the supervisor still wants the process", async () => {
495
572
  const managed = buildLongLived(() => true)
496
573
 
@@ -214,12 +214,15 @@ test("concurrent startup loser re-attests the winner before sending deploy", asy
214
214
  proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0}
215
215
  }, null, 2)}\n`)
216
216
 
217
- const loserDeploy = runReleaseCli(loserRelease, [
218
- "deploy", "--ensure-daemon", "--config", configPath,
219
- "--release-path", loserRelease, "--release-id", "loser",
220
- "--daemon-log-path", path.join(root, "loser.log"), "--daemon-pid-path", loserPidPath,
221
- "--daemon-runtime-path", path.join(root, "loser-runtime")
222
- ])
217
+ const loserRejected = assert.rejects(
218
+ runReleaseCli(loserRelease, [
219
+ "deploy", "--ensure-daemon", "--config", configPath,
220
+ "--release-path", loserRelease, "--release-id", "loser",
221
+ "--daemon-log-path", path.join(root, "loser.log"), "--daemon-pid-path", loserPidPath,
222
+ "--daemon-runtime-path", path.join(root, "loser-runtime")
223
+ ]),
224
+ /legacy or mismatched runtime.*deploy was not sent/s
225
+ )
223
226
 
224
227
  await waitForFile(loserPausedPath)
225
228
  await runReleaseCli(winnerRelease, [
@@ -229,7 +232,7 @@ test("concurrent startup loser re-attests the winner before sending deploy", asy
229
232
  "--daemon-runtime-path", path.join(root, "winner-runtime")
230
233
  ])
231
234
 
232
- await assert.rejects(loserDeploy, /legacy or mismatched runtime.*deploy was not sent/s)
235
+ await loserRejected
233
236
  const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
234
237
 
235
238
  assert.equal(status.activeReleaseId, "winner")
@@ -12,7 +12,7 @@ import {fileURLToPath} from "node:url"
12
12
  import {normalizeConfig} from "../src/config.js"
13
13
  import {sendControlCommand} from "../src/control-client.js"
14
14
  import RollbridgeDaemon from "../src/daemon.js"
15
- import {isProcessAlive} from "../src/state-store.js"
15
+ import {isProcessAlive, readState} from "../src/state-store.js"
16
16
 
17
17
  const dummyAppPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "dummy-app.js")
18
18
 
@@ -152,6 +152,41 @@ test("external-owner retirement releases listeners before a long-draining compan
152
152
  }
153
153
  })
154
154
 
155
+ test("a retired owner cannot clear replacement state during late shutdown", async () => {
156
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-retired-owner-state-"))
157
+ const socketPath = path.join(root, "rollbridge.sock")
158
+ const statePath = path.join(root, "state.json")
159
+ const config = normalizeConfig({...rawConfig(socketPath), statePath})
160
+ const retired = new RollbridgeDaemon({config, logger: () => {}})
161
+ let replacement
162
+
163
+ try {
164
+ await retired.start()
165
+ await retired.deploy({releaseId: "retired", releasePath: root, revision: "retired"})
166
+ if (retired.pendingWrite) await retired.pendingWrite
167
+ await retired.retireOwner({attestation: `sha256:${"a".repeat(64)}`})
168
+
169
+ replacement = new RollbridgeDaemon({config, logger: () => {}})
170
+ await replacement.start({reportOrphans: false})
171
+ await replacement.deploy({releaseId: "replacement", releasePath: root, revision: "replacement"})
172
+ if (replacement.pendingWrite) await replacement.pendingWrite
173
+
174
+ const replacementState = /** @type {{activeReleaseId: string} | undefined} */ (await readState(statePath))
175
+
176
+ assert.equal(replacementState?.activeReleaseId, "replacement")
177
+
178
+ await retired.shutdown()
179
+
180
+ const stateAfterRetiredShutdown = /** @type {{activeReleaseId: string} | undefined} */ (await readState(statePath))
181
+
182
+ assert.equal(stateAfterRetiredShutdown?.activeReleaseId, "replacement", "late shutdown of the retired owner must preserve replacement state")
183
+ } finally {
184
+ if (replacement) await replacement.shutdown()
185
+ await retired.shutdown()
186
+ await fs.rm(root, {force: true, recursive: true})
187
+ }
188
+ })
189
+
155
190
  test("control socket unlink failure is reported only after owned cleanup completes", async () => {
156
191
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-unlink-failure-"))
157
192
  const socketPath = path.join(root, "control.sock")