rollbridge 0.1.22 → 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.22",
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/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([
@@ -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
 
@@ -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")