rollbridge 0.1.22 → 0.1.24

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.22",
3
+ "version": "0.1.24",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/daemon.js CHANGED
@@ -837,6 +837,7 @@ export default class RollbridgeDaemon {
837
837
  }
838
838
  this.persistenceEnabled = false
839
839
  if (this.pendingWrite) await this.pendingWrite
840
+ this.stateCleanupEnabled = false
840
841
  this.controlClosePromise = this.closeServer(this.controlServer)
841
842
  for (const socket of this.controlSockets) if (socket !== completionSocket) socket.destroy()
842
843
  await Promise.all([
@@ -881,12 +882,13 @@ export default class RollbridgeDaemon {
881
882
  }
882
883
 
883
884
  await captureShutdownError(cleanupErrors, "proxy close", async () => this.proxy.close())
884
- const stopResults = await Promise.allSettled([
885
- ...[...this.services.values()].map((processInstance) => processInstance.stop()),
885
+ const dependentStopResults = await Promise.allSettled([
886
886
  ...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
887
887
  ...[...this.startingReleases].map((release) => release.stop()),
888
888
  ...[...this.releases.values()].map((release) => release.stop())
889
889
  ])
890
+ const serviceStopResults = await Promise.allSettled([...this.services.values()].map((processInstance) => processInstance.stop()))
891
+ const stopResults = [...dependentStopResults, ...serviceStopResults]
890
892
  await captureShutdownError(cleanupErrors, "proxy server close", () => this.closeServer(this.proxyServer))
891
893
 
892
894
  // Wait for any in-flight write first so it can't recreate or overwrite the final state (no
@@ -364,18 +364,24 @@ export default class ManagedProcess extends EventEmitter {
364
364
 
365
365
  // 3. Stop whatever is still running, then SIGKILL if it outlasts the graceful window.
366
366
  if (this.processGroupExists(pgid)) {
367
- if (stopCommand) await this.runHook(stopCommand, hookTimeoutMs, "stop command", pgid)
368
- else {
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 {
369
374
  this.intentionalStopSignal = /** @type {ProcessExitSignal} */ (this.stopSignal)
370
- await this.signalProcessGroup(this.stopSignal, pgid, options.timeoutMs ?? this.stopTimeoutMs)
375
+ gracefulDeadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
376
+ await this.signalProcessGroup(this.stopSignal, pgid, gracefulDeadline)
371
377
  }
372
378
 
373
- const timeoutMs = options.timeoutMs ?? this.stopTimeoutMs
374
-
375
- if (!(await this.waitForProcessGroupExit(pgid, timeoutMs))) {
379
+ if (!(await this.waitForProcessGroupExit(pgid, gracefulDeadline))) {
376
380
  this.logger("process stop timed out; sending SIGKILL", {id: this.id, pid: pgid})
377
- await this.signalProcessGroup("SIGKILL", pgid, 5000)
378
- 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)
379
385
  }
380
386
  }
381
387
 
@@ -499,10 +505,10 @@ export default class ManagedProcess extends EventEmitter {
499
505
  * Falls back to the portable group signal when procfs cannot identify group members.
500
506
  * @param {string} signal - Signal name.
501
507
  * @param {number} pgid - Process group id.
502
- * @param {StopTimeoutMs} timeoutMs - Maximum time to wait for descendant reaping.
508
+ * @param {number | undefined} deadline - Absolute graceful deadline, or undefined to wait indefinitely.
503
509
  * @returns {Promise<void>} Resolves after the leader has been signalled.
504
510
  */
505
- async signalProcessGroup(signal, pgid, timeoutMs) {
511
+ async signalProcessGroup(signal, pgid, deadline) {
506
512
  const members = processGroupMembers(pgid)
507
513
  const descendants = members.filter((member) => member.pid !== pgid)
508
514
 
@@ -512,11 +518,11 @@ export default class ManagedProcess extends EventEmitter {
512
518
  }
513
519
 
514
520
  for (const descendant of descendants) this.killProcess(descendant.pid, signal)
515
- const deadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
516
-
517
521
  while (processGroupMembers(pgid).some((member) => member.pid !== pgid)) {
518
522
  if (deadline !== undefined && Date.now() >= deadline) break
519
- await new Promise((resolve) => setTimeout(resolve, 25))
523
+ const waitMs = deadline === undefined ? 25 : Math.min(25, deadline - Date.now())
524
+
525
+ await new Promise((resolve) => setTimeout(resolve, waitMs))
520
526
  }
521
527
 
522
528
  this.killProcess(pgid, signal)
@@ -558,15 +564,15 @@ export default class ManagedProcess extends EventEmitter {
558
564
 
559
565
  /**
560
566
  * @param {number} pgid - Process group id.
561
- * @param {StopTimeoutMs} timeoutMs - Timeout.
567
+ * @param {number | undefined} deadline - Absolute deadline, or undefined to wait indefinitely.
562
568
  * @returns {Promise<boolean>} True once the process group no longer exists.
563
569
  */
564
- async waitForProcessGroupExit(pgid, timeoutMs) {
565
- const deadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
566
-
570
+ async waitForProcessGroupExit(pgid, deadline) {
567
571
  while (this.processGroupExists(pgid)) {
568
572
  if (deadline !== undefined && Date.now() >= deadline) return false
569
- 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))
570
576
  }
571
577
 
572
578
  return true
@@ -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",
@@ -408,12 +410,18 @@ test("sends the configured stopSignal as the graceful stop signal", async () =>
408
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.deepEqual(signals, ["SIGINT", "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 () => {
@@ -527,6 +535,39 @@ test("stop does not return while a gracefully stopped descendant remains unreape
527
535
  }
528
536
  })
529
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
+
530
571
  test("a memory restart respawns and is counted when the supervisor still wants the process", async () => {
531
572
  const managed = buildLongLived(() => true)
532
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
 
@@ -115,6 +115,60 @@ test("shutdown response waits for endpoint and owned-process cleanup before imme
115
115
  }
116
116
  })
117
117
 
118
+ test("shutdown keeps daemon services alive until release-owned dependents stop", async () => {
119
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-service-order-"))
120
+ const socketPath = path.join(root, "control.sock")
121
+ const processCommand = `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`
122
+ const config = buildConfig(socketPath, {
123
+ companion: {command: processCommand, gracefulStopMs: "indefinite", id: "worker", policy: "companion"},
124
+ service: {command: processCommand, id: "coordinator", policy: "service"}
125
+ })
126
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
127
+ /** @type {() => void} */
128
+ let releaseWorker = () => {}
129
+ const workerGate = new Promise((resolve) => { releaseWorker = () => resolve(undefined) })
130
+ let serviceStopStarted = false
131
+
132
+ try {
133
+ await daemon.start()
134
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
135
+
136
+ const release = daemon.activeRelease
137
+ const coordinator = daemon.services.get("coordinator")
138
+
139
+ assert.ok(release)
140
+ assert.ok(coordinator)
141
+
142
+ const originalReleaseStop = release.stop.bind(release)
143
+ const originalCoordinatorStop = coordinator.stop.bind(coordinator)
144
+ let signalReleaseStopStarted = () => {}
145
+ const releaseStopStarted = new Promise((resolve) => { signalReleaseStopStarted = () => resolve(undefined) })
146
+
147
+ release.stop = async () => {
148
+ signalReleaseStopStarted()
149
+ await workerGate
150
+ await originalReleaseStop()
151
+ }
152
+ coordinator.stop = async () => {
153
+ serviceStopStarted = true
154
+ await originalCoordinatorStop()
155
+ }
156
+
157
+ const shutdown = daemon.shutdown()
158
+
159
+ await releaseStopStarted
160
+ const serviceStoppedWhileWorkerWasDraining = serviceStopStarted
161
+ releaseWorker()
162
+ await shutdown
163
+
164
+ assert.equal(serviceStoppedWhileWorkerWasDraining, false, "a worker must retain access to daemon services throughout its drain")
165
+ } finally {
166
+ releaseWorker()
167
+ await daemon.shutdown()
168
+ await fs.rm(root, {force: true, recursive: true})
169
+ }
170
+ })
171
+
118
172
  test("external-owner retirement releases listeners before a long-draining companion exits", async () => {
119
173
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-"))
120
174
  const socketPath = path.join(root, "rollbridge.sock")
@@ -152,6 +206,41 @@ test("external-owner retirement releases listeners before a long-draining compan
152
206
  }
153
207
  })
154
208
 
209
+ test("a retired owner cannot clear replacement state during late shutdown", async () => {
210
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-retired-owner-state-"))
211
+ const socketPath = path.join(root, "rollbridge.sock")
212
+ const statePath = path.join(root, "state.json")
213
+ const config = normalizeConfig({...rawConfig(socketPath), statePath})
214
+ const retired = new RollbridgeDaemon({config, logger: () => {}})
215
+ let replacement
216
+
217
+ try {
218
+ await retired.start()
219
+ await retired.deploy({releaseId: "retired", releasePath: root, revision: "retired"})
220
+ if (retired.pendingWrite) await retired.pendingWrite
221
+ await retired.retireOwner({attestation: `sha256:${"a".repeat(64)}`})
222
+
223
+ replacement = new RollbridgeDaemon({config, logger: () => {}})
224
+ await replacement.start({reportOrphans: false})
225
+ await replacement.deploy({releaseId: "replacement", releasePath: root, revision: "replacement"})
226
+ if (replacement.pendingWrite) await replacement.pendingWrite
227
+
228
+ const replacementState = /** @type {{activeReleaseId: string} | undefined} */ (await readState(statePath))
229
+
230
+ assert.equal(replacementState?.activeReleaseId, "replacement")
231
+
232
+ await retired.shutdown()
233
+
234
+ const stateAfterRetiredShutdown = /** @type {{activeReleaseId: string} | undefined} */ (await readState(statePath))
235
+
236
+ assert.equal(stateAfterRetiredShutdown?.activeReleaseId, "replacement", "late shutdown of the retired owner must preserve replacement state")
237
+ } finally {
238
+ if (replacement) await replacement.shutdown()
239
+ await retired.shutdown()
240
+ await fs.rm(root, {force: true, recursive: true})
241
+ }
242
+ })
243
+
155
244
  test("control socket unlink failure is reported only after owned cleanup completes", async () => {
156
245
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-unlink-failure-"))
157
246
  const socketPath = path.join(root, "control.sock")
@@ -279,13 +368,13 @@ test("shutdown of an already-stopped endpoint fails explicitly", async () => {
279
368
 
280
369
  /**
281
370
  * @param {string} socketPath - Control socket path.
282
- * @param {{companion?: Record<string, import("../src/json.js").JsonValue>}} [options] - Optional companion process.
371
+ * @param {{companion?: Record<string, import("../src/json.js").JsonValue>, service?: Record<string, import("../src/json.js").JsonValue>}} [options] - Optional dependent processes.
283
372
  * @returns {import("../src/config.js").RollbridgeConfig} Normalized config.
284
373
  */
285
- function buildConfig(socketPath, {companion} = {}) {
374
+ function buildConfig(socketPath, {companion, service} = {}) {
286
375
  return normalizeConfig({
287
376
  ...rawConfig(socketPath),
288
- ...(companion ? {processes: [companion, ...rawConfig(socketPath).processes]} : {})
377
+ ...((companion || service) ? {processes: [...(service ? [service] : []), ...(companion ? [companion] : []), ...rawConfig(socketPath).processes]} : {})
289
378
  })
290
379
  }
291
380