rollbridge 0.1.15 → 0.1.17
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 +7 -4
- package/docs/config.md +7 -4
- package/docs/logging.md +6 -0
- package/package.json +1 -1
- package/src/daemon.js +27 -2
- package/src/managed-process.js +55 -13
- package/src/process-memory.js +41 -3
- package/test/daemon-bootstrap.test.js +9 -1
- package/test/managed-process.test.js +86 -0
- package/test/process-memory.test.js +21 -1
- package/test/rollbridge.test.js +33 -2
package/README.md
CHANGED
|
@@ -162,10 +162,13 @@ owns cleaning up on-disk release directories.
|
|
|
162
162
|
releaseRetention: {keep: 5, maxAgeMs: 86400000}
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
-
Set `statePath` to have the daemon persist
|
|
166
|
-
releases, process pids, counters, recent events).
|
|
167
|
-
|
|
168
|
-
|
|
165
|
+
Set `statePath` to have the daemon persist secret-safe recovery state to a file
|
|
166
|
+
(active/draining releases, process pids, counters, sanitized recent events).
|
|
167
|
+
Commands, environment mappings, child command lines, and captured process output
|
|
168
|
+
are deliberately excluded; those diagnostics remain available through the live
|
|
169
|
+
status/log/event APIs. On the next startup Rollbridge reads any leftover file and
|
|
170
|
+
reports managed processes still alive from a daemon that didn't shut down cleanly
|
|
171
|
+
— advisory orphan detection. After a crash, run
|
|
169
172
|
`rollbridge recover` to list those leftovers and `rollbridge recover --force` to
|
|
170
173
|
stop them before restarting the daemon. A clean `shutdown` removes the file. See
|
|
171
174
|
[`docs/config.md`](docs/config.md#statepath).
|
package/docs/config.md
CHANGED
|
@@ -90,10 +90,13 @@ release records; the deploy tool still owns on-disk release directories.
|
|
|
90
90
|
|
|
91
91
|
## `statePath`
|
|
92
92
|
|
|
93
|
-
When set, the daemon persists a state snapshot — the active and
|
|
94
|
-
releases, each managed process's metadata (including pid),
|
|
95
|
-
recent events — to this file (atomically, on
|
|
96
|
-
|
|
93
|
+
When set, the daemon persists a secret-safe state snapshot — the active and
|
|
94
|
+
draining releases, each managed process's recovery metadata (including pid),
|
|
95
|
+
restart counters, and recent structured events — to this file (atomically, on
|
|
96
|
+
changes and every few seconds). Process commands, working directories,
|
|
97
|
+
environment mappings, child command lines, and retained stdout/stderr are never
|
|
98
|
+
persisted. They remain available from the live `status`, `logs`, and `events`
|
|
99
|
+
APIs while the daemon is running. On a clean `shutdown` the file is removed.
|
|
97
100
|
|
|
98
101
|
On the **next startup**, the daemon reads any leftover file and reports managed
|
|
99
102
|
processes whose pids are still alive — likely orphans from a daemon that crashed
|
package/docs/logging.md
CHANGED
|
@@ -36,6 +36,12 @@ events. It is distinct from the two in-memory views:
|
|
|
36
36
|
- `rollbridge events` — the recent structured daemon event history (the most
|
|
37
37
|
recent 1000 events), the same events written to the log file.
|
|
38
38
|
|
|
39
|
+
When `statePath` is configured, its recovery snapshot is intentionally not a
|
|
40
|
+
log archive: process commands, environment mappings, child command lines, and
|
|
41
|
+
captured stdout/stderr are excluded. Use the live APIs above or the configured
|
|
42
|
+
daemon log for those diagnostics, and protect that log according to the
|
|
43
|
+
sensitivity of application output.
|
|
44
|
+
|
|
39
45
|
Both are cleared when the daemon restarts; the log file persists.
|
|
40
46
|
|
|
41
47
|
## Rotation
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -721,8 +721,9 @@ export default class RollbridgeDaemon {
|
|
|
721
721
|
if (!this.statePath || this.stopping) return
|
|
722
722
|
|
|
723
723
|
const statePath = this.statePath
|
|
724
|
-
const status = this.status()
|
|
725
|
-
const
|
|
724
|
+
const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
|
|
725
|
+
const events = secretSafeStateValue(this.eventLog.recent())
|
|
726
|
+
const snapshot = {...status, events, persistedAt: new Date().toISOString()}
|
|
726
727
|
|
|
727
728
|
// Serialize writes (and track the tail) so shutdown can wait for an in-flight write before
|
|
728
729
|
// clearing the file — otherwise a write started before shutdown could recreate it afterward.
|
|
@@ -851,6 +852,30 @@ function stringOrUndefined(value) {
|
|
|
851
852
|
return value
|
|
852
853
|
}
|
|
853
854
|
|
|
855
|
+
const SECRET_BEARING_STATE_KEYS = new Set(["children", "command", "cwd", "env", "environment", "logs", "output"])
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Removes process definitions and captured output from a value before it reaches statePath.
|
|
859
|
+
* The live status/events APIs retain those diagnostics in memory; persistent state is only a
|
|
860
|
+
* secret-safe recovery aid and must not become a second process log or configuration store.
|
|
861
|
+
* @param {JsonValue} value - JSON value to sanitize.
|
|
862
|
+
* @returns {JsonValue} A secret-safe copy.
|
|
863
|
+
*/
|
|
864
|
+
function secretSafeStateValue(value) {
|
|
865
|
+
if (Array.isArray(value)) return value.map((entry) => secretSafeStateValue(entry))
|
|
866
|
+
if (!value || typeof value !== "object") return value
|
|
867
|
+
|
|
868
|
+
/** @type {Record<string, JsonValue>} */
|
|
869
|
+
const safe = {}
|
|
870
|
+
|
|
871
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
872
|
+
if (SECRET_BEARING_STATE_KEYS.has(key)) continue
|
|
873
|
+
safe[key] = secretSafeStateValue(entry)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return safe
|
|
877
|
+
}
|
|
878
|
+
|
|
854
879
|
/**
|
|
855
880
|
* @param {JsonValue} value - Value.
|
|
856
881
|
* @param {string} key - Key.
|
package/src/managed-process.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
380
|
-
this.logger("process stop timed out; sending SIGKILL", {id: this.id, pid:
|
|
381
|
-
this.killProcessGroup("SIGKILL")
|
|
382
|
-
await this.
|
|
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:
|
|
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 (!
|
|
473
|
+
killProcessGroup(signal, pgid = this.pid) {
|
|
474
|
+
if (!pgid) return
|
|
468
475
|
|
|
469
476
|
try {
|
|
470
|
-
process.kill(-
|
|
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.
|
package/src/process-memory.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
/**
|
|
@@ -20,6 +20,8 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
|
|
|
20
20
|
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1"], message: /must be provided together/},
|
|
21
21
|
{args: ["--config", "relative/config.js", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123"], message: /--config must be an absolute path/},
|
|
22
22
|
{args: ["--config", "CONFIG", "--release-path", "relative/release", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be an absolute path/},
|
|
23
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE_UNNORMALIZED", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be normalized/},
|
|
24
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE_MISSING", "--release-id", "v1", "--revision", "abc123"], message: /--release-path is not accessible/},
|
|
23
25
|
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "unsafe id", "--revision", "abc123"], message: /--release-id/},
|
|
24
26
|
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "unsafe revision"], message: /--revision/}
|
|
25
27
|
]
|
|
@@ -27,7 +29,13 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
|
|
|
27
29
|
for (const testCase of cases) {
|
|
28
30
|
await t.test(testCase.message.source, async () => {
|
|
29
31
|
const fixture = await createFixture()
|
|
30
|
-
const args = testCase.args.map((arg) =>
|
|
32
|
+
const args = testCase.args.map((arg) => {
|
|
33
|
+
if (arg === "CONFIG") return fixture.configPath
|
|
34
|
+
if (arg === "RELEASE") return fixture.root
|
|
35
|
+
if (arg === "RELEASE_UNNORMALIZED") return `${fixture.root}/child/..`
|
|
36
|
+
if (arg === "RELEASE_MISSING") return path.join(fixture.root, "missing")
|
|
37
|
+
return arg
|
|
38
|
+
})
|
|
31
39
|
|
|
32
40
|
try {
|
|
33
41
|
const result = await runDaemon(args)
|
|
@@ -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
|
|
|
@@ -467,6 +544,10 @@ test("does not auto-restart when the restart policy is disabled (maxRestarts: 0)
|
|
|
467
544
|
|
|
468
545
|
test("stops auto-restarting once maxRestarts within the window is reached", async () => {
|
|
469
546
|
const managed = buildCrasher({backoffFactor: 1, maxDelayMs: 0, maxRestarts: 2, windowMs: 60000})
|
|
547
|
+
/** @type {{data: Record<string, import("../src/json.js").JsonValue>, message: string}[]} */
|
|
548
|
+
const events = []
|
|
549
|
+
|
|
550
|
+
managed.logger = (message, data = {}) => { events.push({data, message}) }
|
|
470
551
|
|
|
471
552
|
try {
|
|
472
553
|
await managed.start()
|
|
@@ -477,6 +558,11 @@ test("stops auto-restarting once maxRestarts within the window is reached", asyn
|
|
|
477
558
|
|
|
478
559
|
assert.equal(managed.status().restarts, 2)
|
|
479
560
|
assert.equal(managed.status().state, "failed")
|
|
561
|
+
assert.deepEqual(events.find((event) => event.message === "restart limit reached")?.data, {
|
|
562
|
+
id: "crasher",
|
|
563
|
+
maxRestarts: 2,
|
|
564
|
+
windowMs: 60000
|
|
565
|
+
})
|
|
480
566
|
} finally {
|
|
481
567
|
await managed.stop()
|
|
482
568
|
}
|
|
@@ -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
|
|
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
|
+
})
|
package/test/rollbridge.test.js
CHANGED
|
@@ -8,11 +8,11 @@ import net from "node:net"
|
|
|
8
8
|
import os from "node:os"
|
|
9
9
|
import path from "node:path"
|
|
10
10
|
import test from "node:test"
|
|
11
|
-
import {fileURLToPath} from "node:url"
|
|
11
|
+
import {fileURLToPath, pathToFileURL} from "node:url"
|
|
12
12
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
13
13
|
import {normalizeConfig} from "../src/config.js"
|
|
14
14
|
import {sendControlCommand} from "../src/control-client.js"
|
|
15
|
-
import {readState, writeState} from "../src/state-store.js"
|
|
15
|
+
import {liveProcesses, readState, writeState} from "../src/state-store.js"
|
|
16
16
|
import {runCli} from "../src/cli.js"
|
|
17
17
|
|
|
18
18
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -635,6 +635,37 @@ test("persists daemon state to statePath and removes it on a clean shutdown", as
|
|
|
635
635
|
assert.equal(stateAfterShutdown, undefined, "state file removed on clean shutdown")
|
|
636
636
|
})
|
|
637
637
|
|
|
638
|
+
test("persisted daemon state excludes process commands, environment values, and output", async () => {
|
|
639
|
+
const secret = "state-secret-value"
|
|
640
|
+
const fixture = await createFixture({persistState: true})
|
|
641
|
+
const web = fixture.config.processes.find((processConfig) => processConfig.id === "web")
|
|
642
|
+
|
|
643
|
+
assert.ok(web)
|
|
644
|
+
web.env.ROLLBRIDGE_TEST_SECRET = secret
|
|
645
|
+
web.command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`console.log(process.env.ROLLBRIDGE_TEST_SECRET); import(${JSON.stringify(pathToFileURL(dummyAppPath).href)})`)}`
|
|
646
|
+
|
|
647
|
+
const daemon = await startDaemon(fixture.config)
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
651
|
+
await waitFor(() => Boolean(daemon.activeRelease?.getProcess("web")?.status().logs.some((entry) => entry.line === secret)))
|
|
652
|
+
|
|
653
|
+
daemon.persistState()
|
|
654
|
+
await waitFor(async () => (await fs.readFile(fixture.statePath, "utf8")).includes('"activeReleaseId": "v1"'))
|
|
655
|
+
|
|
656
|
+
const persisted = await fs.readFile(fixture.statePath, "utf8")
|
|
657
|
+
|
|
658
|
+
assert.doesNotMatch(persisted, /state-secret-value/)
|
|
659
|
+
assert.doesNotMatch(persisted, /ROLLBRIDGE_TEST_SECRET/)
|
|
660
|
+
assert.doesNotMatch(persisted, /"command"/)
|
|
661
|
+
assert.doesNotMatch(persisted, /"logs"/)
|
|
662
|
+
assert.deepEqual(liveProcesses(JSON.parse(persisted), () => true).map(({id, releaseId}) => ({id, releaseId})), [{id: "web", releaseId: "v1"}])
|
|
663
|
+
} finally {
|
|
664
|
+
await daemon.shutdown()
|
|
665
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
666
|
+
}
|
|
667
|
+
})
|
|
668
|
+
|
|
638
669
|
test("a clean shutdown clears the state file even when a persist write is in flight", async () => {
|
|
639
670
|
const fixture = await createFixture({persistState: true})
|
|
640
671
|
const daemon = await startDaemon(fixture.config)
|