rollbridge 0.1.39 → 0.1.41
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 +22 -4
- package/changelog.d/20260830-guardian-retired-replacement-process-key.md +12 -1
- package/changelog.d/20260830-release-generation-activation-lifecycle.md +16 -2
- package/changelog.d/20260830055159-guardian-daemon-restart.md +2 -0
- package/docs/cli.md +23 -9
- package/docs/config.md +37 -13
- package/docs/logging.md +4 -3
- package/docs/troubleshooting.md +7 -5
- package/examples/tensorbuzz.com.js +5 -2
- package/package.json +1 -1
- package/src/cli.js +118 -24
- package/src/config.js +35 -8
- package/src/daemon.js +822 -152
- package/src/guardian-client.js +121 -16
- package/src/managed-process.js +117 -15
- package/src/process-guardian.js +734 -43
- package/src/release-group.js +45 -7
- package/test/completion.test.js +4 -2
- package/test/config-examples.test.js +1 -0
- package/test/config-validation.test.js +22 -0
- package/test/fixtures/guardian-recovery-owner.js +86 -0
- package/test/fixtures/pre-split3-process-guardian.js +14 -0
- package/test/guardian-client.test.js +1420 -62
- package/test/managed-process.test.js +163 -7
- package/test/owner-recovery.test.js +575 -73
- package/test/owner-replacement.test.js +525 -28
- package/test/release-group.test.js +19 -2
- package/test/release-runtime-retention.test.js +121 -4
- package/test/rollbridge.test.js +263 -13
- package/test/support/process.js +41 -0
|
@@ -66,6 +66,26 @@ test("keeps every output line when fewer than the retention limit are produced",
|
|
|
66
66
|
assert.deepEqual(logs.map((entry) => entry.line), ["one", "two"])
|
|
67
67
|
})
|
|
68
68
|
|
|
69
|
+
test("reassembles output lines split across stream chunks", () => {
|
|
70
|
+
const managed = buildProcess(50)
|
|
71
|
+
|
|
72
|
+
managed.appendLog("stdout", "8191:x")
|
|
73
|
+
assert.deepEqual(managed.status().logs, [])
|
|
74
|
+
|
|
75
|
+
managed.appendLog("stdout", "xx\n")
|
|
76
|
+
assert.deepEqual(managed.status().logs.map((entry) => entry.line), ["8191:xxx"])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("bounds an output fragment that never terminates", () => {
|
|
80
|
+
const managed = buildProcess(50)
|
|
81
|
+
const fragment = "x".repeat(1024 * 1024)
|
|
82
|
+
|
|
83
|
+
managed.appendLog("stdout", fragment)
|
|
84
|
+
|
|
85
|
+
assert.equal(managed.status().logs.length, 15)
|
|
86
|
+
assert.equal(managed.outputBuffers.stdout.length, 64 * 1024)
|
|
87
|
+
})
|
|
88
|
+
|
|
69
89
|
test("emits each output line after retaining it", () => {
|
|
70
90
|
const managed = buildProcess(50)
|
|
71
91
|
let observed
|
|
@@ -198,6 +218,27 @@ test("records the manual start reason", async () => {
|
|
|
198
218
|
}
|
|
199
219
|
})
|
|
200
220
|
|
|
221
|
+
test("a later stop cancels a start queued behind an in-flight stop", async () => {
|
|
222
|
+
const managed = buildProcess(50)
|
|
223
|
+
let finishStop = () => {}
|
|
224
|
+
|
|
225
|
+
managed.command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`
|
|
226
|
+
managed.stopPromise = new Promise((resolve) => {
|
|
227
|
+
finishStop = resolve
|
|
228
|
+
})
|
|
229
|
+
const queuedStart = managed.start()
|
|
230
|
+
const finalStop = managed.stop()
|
|
231
|
+
|
|
232
|
+
finishStop()
|
|
233
|
+
await Promise.all([queuedStart, finalStop])
|
|
234
|
+
try {
|
|
235
|
+
assert.equal(managed.status().pid, undefined)
|
|
236
|
+
assert.equal(managed.status().state, "stopped")
|
|
237
|
+
} finally {
|
|
238
|
+
await managed.stop()
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
201
242
|
test("does not record a start reason when the spawn fails", async () => {
|
|
202
243
|
const managed = new ManagedProcess({
|
|
203
244
|
command: "true",
|
|
@@ -404,18 +445,133 @@ test("activateStrict runs the configured activation command once per call and re
|
|
|
404
445
|
const commands = /** @type {{command: string, label: string, pid: number | undefined, timeoutMs: number}[]} */ ([])
|
|
405
446
|
const managed = buildLongLived(() => false)
|
|
406
447
|
|
|
448
|
+
try {
|
|
449
|
+
await managed.start()
|
|
450
|
+
const pid = managed.pid
|
|
451
|
+
|
|
452
|
+
assert.ok(pid)
|
|
453
|
+
managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0}
|
|
454
|
+
managed.runHook = async (command, timeoutMs, label, hookPid) => {
|
|
455
|
+
commands.push({command, label, pid: hookPid, timeoutMs})
|
|
456
|
+
return undefined
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
await managed.activateStrict()
|
|
460
|
+
assert.deepEqual(commands, [{command: "jobs activate", label: "activate command", pid, timeoutMs: 30000}])
|
|
461
|
+
|
|
462
|
+
managed.runHook = async () => new Error("activation rejected")
|
|
463
|
+
await assert.rejects(() => managed.activateStrict(), /activation rejected/)
|
|
464
|
+
} finally {
|
|
465
|
+
await managed.stop()
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
test("activateStrict rejects when the activated process is replaced while its hook runs", async () => {
|
|
470
|
+
const managed = buildLongLived(() => false)
|
|
471
|
+
|
|
472
|
+
await managed.start()
|
|
473
|
+
const child = managed.child
|
|
474
|
+
const pid = managed.pid
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0}
|
|
478
|
+
managed.lifecycleRole = "candidate"
|
|
479
|
+
managed.runHook = async () => {
|
|
480
|
+
managed.child = undefined
|
|
481
|
+
managed.pid = undefined
|
|
482
|
+
return undefined
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
await assert.rejects(() => managed.activateStrict(), /exited before activation completed/)
|
|
486
|
+
assert.equal(managed.lifecycleRole, "candidate")
|
|
487
|
+
} finally {
|
|
488
|
+
managed.child = child
|
|
489
|
+
managed.pid = pid
|
|
490
|
+
await managed.stop()
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
test("activateStrict rejects an activation request when its process is not running", async () => {
|
|
495
|
+
const managed = buildLongLived(() => false)
|
|
496
|
+
let hookRan = false
|
|
497
|
+
|
|
407
498
|
managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0}
|
|
408
|
-
managed.
|
|
409
|
-
|
|
410
|
-
|
|
499
|
+
managed.runHook = async () => {
|
|
500
|
+
hookRan = true
|
|
501
|
+
return undefined
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
await assert.rejects(() => managed.activateStrict(), /is not running for activation/)
|
|
505
|
+
assert.equal(hookRan, false)
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
test("reactivateStrict restores a retained quiesced process only after activation succeeds", async () => {
|
|
509
|
+
const managed = buildLongLived(() => false)
|
|
510
|
+
const hooks = /** @type {string[]} */ ([])
|
|
511
|
+
|
|
512
|
+
managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0, quietCommand: "jobs retire"}
|
|
513
|
+
managed.runHook = async (_command, _timeoutMs, label) => {
|
|
514
|
+
hooks.push(label)
|
|
515
|
+
if (label === "activate command" && hooks.length === 2) return new Error("restoration rejected")
|
|
411
516
|
return undefined
|
|
412
517
|
}
|
|
413
518
|
|
|
414
|
-
|
|
415
|
-
|
|
519
|
+
try {
|
|
520
|
+
await managed.start()
|
|
521
|
+
await managed.quiesceStrict()
|
|
522
|
+
await assert.rejects(() => managed.reactivateStrict(), /restoration rejected/)
|
|
523
|
+
assert.equal(managed.status().state, "quiesced")
|
|
524
|
+
assert.equal(managed.status().lifecycleRole, "retired")
|
|
525
|
+
|
|
526
|
+
await managed.reactivateStrict()
|
|
527
|
+
assert.equal(managed.status().state, "running")
|
|
528
|
+
assert.equal(managed.status().lifecycleRole, "active")
|
|
529
|
+
assert.deepEqual(hooks, ["quiet command", "activate command", "activate command"])
|
|
530
|
+
} finally {
|
|
531
|
+
await managed.stop()
|
|
532
|
+
}
|
|
533
|
+
})
|
|
416
534
|
|
|
417
|
-
|
|
418
|
-
|
|
535
|
+
test("quiesce waits for active-role restoration before retiring a restarted process", async () => {
|
|
536
|
+
const managed = buildLongLived(() => false)
|
|
537
|
+
const hooks = /** @type {string[]} */ ([])
|
|
538
|
+
/** @type {() => void} */
|
|
539
|
+
let allowActivation = () => {}
|
|
540
|
+
/** @type {() => void} */
|
|
541
|
+
let markActivationStarted = () => {}
|
|
542
|
+
const activationAllowed = new Promise((resolve) => { allowActivation = () => resolve(undefined) })
|
|
543
|
+
const activationStarted = new Promise((resolve) => { markActivationStarted = () => resolve(undefined) })
|
|
544
|
+
|
|
545
|
+
managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0, quietCommand: "jobs retire"}
|
|
546
|
+
managed.runHook = async (_command, _timeoutMs, label) => {
|
|
547
|
+
if (label === "activate command") {
|
|
548
|
+
hooks.push("activate:start")
|
|
549
|
+
markActivationStarted()
|
|
550
|
+
await activationAllowed
|
|
551
|
+
hooks.push("activate:end")
|
|
552
|
+
} else {
|
|
553
|
+
hooks.push("quiet")
|
|
554
|
+
}
|
|
555
|
+
return undefined
|
|
556
|
+
}
|
|
557
|
+
const start = assert.rejects(() => managed.start("crash", "active"), /quiesced before lifecycle role active was restored/)
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
await activationStarted
|
|
561
|
+
const quiesce = managed.quiesceStrict()
|
|
562
|
+
|
|
563
|
+
await Promise.resolve()
|
|
564
|
+
assert.deepEqual(hooks, ["activate:start"], "retirement must not race ahead of role restoration")
|
|
565
|
+
allowActivation()
|
|
566
|
+
await Promise.all([start, quiesce])
|
|
567
|
+
assert.deepEqual(hooks, ["activate:start", "activate:end", "quiet"])
|
|
568
|
+
assert.equal(managed.status().state, "quiesced")
|
|
569
|
+
assert.equal(managed.lifecycleRole, "retired")
|
|
570
|
+
} finally {
|
|
571
|
+
allowActivation()
|
|
572
|
+
await start.catch(() => {})
|
|
573
|
+
await managed.stop()
|
|
574
|
+
}
|
|
419
575
|
})
|
|
420
576
|
|
|
421
577
|
test("sends the configured stopSignal as the graceful stop signal", async () => {
|