rollbridge 0.1.15 → 0.1.16

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.15",
3
+ "version": "0.1.16",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
@@ -2,7 +2,7 @@
2
2
 
3
3
  import {EventEmitter} from "node:events"
4
4
  import {spawn} from "node:child_process"
5
- import {processGroupMembers} from "./process-memory.js"
5
+ import {processGroupHasLiveMembers, processGroupMembers} from "./process-memory.js"
6
6
 
7
7
  /**
8
8
  * @typedef {import("./json.js").JsonValue} JsonValue
@@ -353,6 +353,9 @@ export default class ManagedProcess extends EventEmitter {
353
353
  return
354
354
  }
355
355
 
356
+ const pgid = child.pid
357
+ const exitPromise = this.exitPromise
358
+
356
359
  this.state = "stopping"
357
360
 
358
361
  const {drainCommand, drainTimeoutMs, quietCommand, stopCommand} = this.lifecycle
@@ -370,19 +373,21 @@ export default class ManagedProcess extends EventEmitter {
370
373
  }
371
374
 
372
375
  // 3. Stop whatever is still running, then SIGKILL if it outlasts the graceful window.
373
- if (this.child) {
374
- if (stopCommand) await this.runHook(stopCommand, hookTimeoutMs, "stop command")
375
- else this.killProcessGroup(this.stopSignal)
376
+ if (this.processGroupExists(pgid)) {
377
+ if (stopCommand) await this.runHook(stopCommand, hookTimeoutMs, "stop command", pgid)
378
+ else this.killProcessGroup(this.stopSignal, pgid)
376
379
 
377
380
  const timeoutMs = options.timeoutMs ?? this.stopTimeoutMs
378
381
 
379
- if (this.child && !(await this.waitForExit(timeoutMs))) {
380
- this.logger("process stop timed out; sending SIGKILL", {id: this.id, pid: this.pid})
381
- this.killProcessGroup("SIGKILL")
382
- await this.waitForExit(5000)
382
+ if (!(await this.waitForProcessGroupExit(pgid, timeoutMs))) {
383
+ this.logger("process stop timed out; sending SIGKILL", {id: this.id, pid: pgid})
384
+ this.killProcessGroup("SIGKILL", pgid)
385
+ await this.waitForProcessGroupExit(pgid, 5000)
383
386
  }
384
387
  }
385
388
 
389
+ if (exitPromise) await exitPromise
390
+
386
391
  this.state = "stopped"
387
392
  }
388
393
 
@@ -400,9 +405,10 @@ export default class ManagedProcess extends EventEmitter {
400
405
  * @param {string} command - Shell command to run.
401
406
  * @param {number} timeoutMs - Maximum time to wait for the hook before killing it.
402
407
  * @param {string} label - Hook name, for log messages.
408
+ * @param {number | undefined} [pid] - Process-group leader exposed to the hook.
403
409
  * @returns {Promise<void>} Resolves when the hook exits, errors, or times out.
404
410
  */
405
- async runHook(command, timeoutMs, label) {
411
+ async runHook(command, timeoutMs, label, pid = this.pid) {
406
412
  await new Promise((resolve) => {
407
413
  let settled = false
408
414
  const finish = () => { if (!settled) { settled = true; resolve(undefined) } }
@@ -414,7 +420,7 @@ export default class ManagedProcess extends EventEmitter {
414
420
  hook = spawn(command, {
415
421
  cwd: this.cwd,
416
422
  detached: true,
417
- env: {...process.env, ...this.env, ROLLBRIDGE_PID: this.pid ? String(this.pid) : ""},
423
+ env: {...process.env, ...this.env, ROLLBRIDGE_PID: pid ? String(pid) : ""},
418
424
  shell: true,
419
425
  stdio: "ignore"
420
426
  })
@@ -461,19 +467,55 @@ export default class ManagedProcess extends EventEmitter {
461
467
 
462
468
  /**
463
469
  * @param {string} signal - Signal name to send (the configured stop signal, or "SIGKILL").
470
+ * @param {number | undefined} [pgid] - Process group id (the current child pid by default).
464
471
  * @returns {void}
465
472
  */
466
- killProcessGroup(signal) {
467
- if (!this.child || !this.child.pid) return
473
+ killProcessGroup(signal, pgid = this.pid) {
474
+ if (!pgid) return
468
475
 
469
476
  try {
470
- process.kill(-this.child.pid, signal)
477
+ process.kill(-pgid, signal)
471
478
  } catch (error) {
472
479
  if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return
473
480
  throw error
474
481
  }
475
482
  }
476
483
 
484
+ /**
485
+ * @param {number} pgid - Process group id.
486
+ * @returns {boolean} True until the process group no longer exists.
487
+ */
488
+ processGroupExists(pgid) {
489
+ const hasLiveMembers = processGroupHasLiveMembers(pgid)
490
+
491
+ if (hasLiveMembers !== undefined) return hasLiveMembers
492
+
493
+ try {
494
+ process.kill(-pgid, 0)
495
+
496
+ return true
497
+ } catch (error) {
498
+ if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false
499
+ throw error
500
+ }
501
+ }
502
+
503
+ /**
504
+ * @param {number} pgid - Process group id.
505
+ * @param {StopTimeoutMs} timeoutMs - Timeout.
506
+ * @returns {Promise<boolean>} True once the process group no longer exists.
507
+ */
508
+ async waitForProcessGroupExit(pgid, timeoutMs) {
509
+ const deadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
510
+
511
+ while (this.processGroupExists(pgid)) {
512
+ if (deadline !== undefined && Date.now() >= deadline) return false
513
+ await new Promise((resolve) => setTimeout(resolve, 10))
514
+ }
515
+
516
+ return true
517
+ }
518
+
477
519
  /**
478
520
  * @param {StopTimeoutMs} timeoutMs - Timeout.
479
521
  * @returns {Promise<boolean>} True when the process exited before timeout.
@@ -42,6 +42,34 @@ export function processGroupMembers(pgid) {
42
42
  return members
43
43
  }
44
44
 
45
+ /**
46
+ * Reports whether a process group has a member that can still run. Defunct members remain
47
+ * visible to kill(2) until their parent reaps them, but cannot handle signals or do work.
48
+ * @param {number} pgid - Process-group id.
49
+ * @param {string} [procPath] - Procfs root (overridable for deterministic tests).
50
+ * @returns {boolean | undefined} Whether a live member exists, or undefined without procfs.
51
+ */
52
+ export function processGroupHasLiveMembers(pgid, procPath = "/proc") {
53
+ /** @type {string[]} */
54
+ let entries
55
+
56
+ try {
57
+ entries = fs.readdirSync(procPath)
58
+ } catch {
59
+ return undefined
60
+ }
61
+
62
+ for (const entry of entries) {
63
+ if (!/^\d+$/.test(entry)) continue
64
+
65
+ const stat = processStat(entry, procPath)
66
+
67
+ if (stat?.pgrp === pgid && stat.state !== "Z" && stat.state !== "X") return true
68
+ }
69
+
70
+ return false
71
+ }
72
+
45
73
  /**
46
74
  * Measures the total resident memory (RSS) of a managed process group.
47
75
  * @param {number} pgid - Process-group id (the detached spawn's pid).
@@ -72,10 +100,19 @@ function commandName(pid) {
72
100
  * @returns {number | undefined} The process-group id, or undefined when the process is gone.
73
101
  */
74
102
  function processGroupId(pid) {
103
+ return processStat(pid, "/proc")?.pgrp
104
+ }
105
+
106
+ /**
107
+ * @param {string} pid - Process id.
108
+ * @param {string} procPath - Procfs root.
109
+ * @returns {{pgrp: number, state: string} | undefined} Parsed process state and group.
110
+ */
111
+ function processStat(pid, procPath) {
75
112
  let stat
76
113
 
77
114
  try {
78
- stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8")
115
+ stat = fs.readFileSync(`${procPath}/${pid}/stat`, "utf8")
79
116
  } catch {
80
117
  return undefined
81
118
  }
@@ -86,9 +123,10 @@ function processGroupId(pid) {
86
123
 
87
124
  if (commEnd < 0) return undefined
88
125
 
89
- const pgrp = Number(stat.slice(commEnd + 2).split(" ")[2])
126
+ const fields = stat.slice(commEnd + 2).split(" ")
127
+ const pgrp = Number(fields[2])
90
128
 
91
- return Number.isInteger(pgrp) ? pgrp : undefined
129
+ return Number.isInteger(pgrp) ? {pgrp, state: fields[0]} : undefined
92
130
  }
93
131
 
94
132
  /**
@@ -292,6 +292,41 @@ test("a configured stopCommand is used instead of the stop signal", async () =>
292
292
  }
293
293
  })
294
294
 
295
+ test("stopCommand receives the retained process group id after the shell exits", async () => {
296
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rollbridge-hooks-"))
297
+ const pidPath = path.join(dir, "stop-pid")
298
+ const managed = new ManagedProcess({
299
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")} & wait`,
300
+ cwd: undefined,
301
+ env: {},
302
+ id: "worker",
303
+ lifecycle: {
304
+ drainTimeoutMs: 0,
305
+ quietCommand: "kill -TERM $ROLLBRIDGE_PID; sleep 0.1",
306
+ stopCommand: `echo $ROLLBRIDGE_PID > ${JSON.stringify(pidPath)}; kill -TERM -$ROLLBRIDGE_PID`
307
+ },
308
+ logger: () => {},
309
+ outputLines: 50,
310
+ restartDelayMs: 10,
311
+ shouldRestart: () => false,
312
+ stopSignal: "SIGTERM",
313
+ stopTimeoutMs: 1000
314
+ })
315
+
316
+ try {
317
+ await managed.start()
318
+
319
+ const pgid = managed.pid
320
+
321
+ await managed.stop()
322
+
323
+ assert.equal(fs.readFileSync(pidPath, "utf8").trim(), String(pgid))
324
+ } finally {
325
+ await managed.stop()
326
+ fs.rmSync(dir, {force: true, recursive: true})
327
+ }
328
+ })
329
+
295
330
  test("a failing lifecycle hook is logged but does not fail the stop", async () => {
296
331
  /** @type {string[]} */
297
332
  const messages = []
@@ -414,6 +449,48 @@ test("indefinite stop waits for the process to exit without SIGKILL", async () =
414
449
  }
415
450
  })
416
451
 
452
+ test("stop waits for process group descendants after the detached shell exits", async () => {
453
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rollbridge-process-group-"))
454
+ const readyPath = path.join(dir, "ready")
455
+ const latePath = path.join(dir, "late")
456
+ const descendant = [
457
+ "const fs = require('node:fs')",
458
+ `process.on('SIGTERM', () => setTimeout(() => { fs.writeFileSync(${JSON.stringify(latePath)}, 'late'); process.exit(0) }, 300))`,
459
+ `fs.writeFileSync(${JSON.stringify(readyPath)}, 'ready')`,
460
+ "setInterval(() => {}, 1000)"
461
+ ].join("; ")
462
+ const managed = new ManagedProcess({
463
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(descendant)} & wait`,
464
+ cwd: undefined,
465
+ env: {},
466
+ id: "worker",
467
+ logger: () => {},
468
+ outputLines: 50,
469
+ restartDelayMs: 10,
470
+ shouldRestart: () => false,
471
+ stopSignal: "SIGTERM",
472
+ stopTimeoutMs: 2000
473
+ })
474
+
475
+ try {
476
+ await managed.start()
477
+ await waitFor(() => fs.existsSync(readyPath))
478
+
479
+ const startedAt = Date.now()
480
+
481
+ await managed.stop()
482
+
483
+ const elapsedMs = Date.now() - startedAt
484
+
485
+ assert.ok(elapsedMs >= 250, `stop resolved after only ${elapsedMs}ms`)
486
+ assert.ok(elapsedMs < 1500, `stop took ${elapsedMs}ms`)
487
+ assert.equal(fs.readFileSync(latePath, "utf8"), "late")
488
+ } finally {
489
+ await managed.stop()
490
+ fs.rmSync(dir, {force: true, recursive: true})
491
+ }
492
+ })
493
+
417
494
  test("a memory restart respawns and is counted when the supervisor still wants the process", async () => {
418
495
  const managed = buildLongLived(() => true)
419
496
 
@@ -3,7 +3,9 @@
3
3
  import assert from "node:assert/strict"
4
4
  import fs from "node:fs"
5
5
  import test from "node:test"
6
- import {measureProcessGroupRssBytes, processGroupMembers} from "../src/process-memory.js"
6
+ import os from "node:os"
7
+ import path from "node:path"
8
+ import {measureProcessGroupRssBytes, processGroupHasLiveMembers, processGroupMembers} from "../src/process-memory.js"
7
9
 
8
10
  const linuxOnly = process.platform !== "linux" && "requires /proc (Linux)"
9
11
 
@@ -38,3 +40,21 @@ test("lists process-group members with their command and resident memory", {skip
38
40
  test("returns an empty list for a process group with no members", {skip: linuxOnly}, () => {
39
41
  assert.deepEqual(processGroupMembers(2147483646), [])
40
42
  })
43
+
44
+ test("treats a process group containing only defunct members as stopped", () => {
45
+ const procPath = fs.mkdtempSync(path.join(os.tmpdir(), "rollbridge-proc-"))
46
+
47
+ try {
48
+ fs.mkdirSync(path.join(procPath, "101"))
49
+ fs.writeFileSync(path.join(procPath, "101", "stat"), "101 (worker) Z 1 77 0 0")
50
+
51
+ assert.equal(processGroupHasLiveMembers(77, procPath), false)
52
+
53
+ fs.mkdirSync(path.join(procPath, "102"))
54
+ fs.writeFileSync(path.join(procPath, "102", "stat"), "102 (worker) S 1 77 0 0")
55
+
56
+ assert.equal(processGroupHasLiveMembers(77, procPath), true)
57
+ } finally {
58
+ fs.rmSync(procPath, {force: true, recursive: true})
59
+ }
60
+ })