rollbridge 0.1.49 → 0.1.55
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/AGENTS.md +5 -0
- package/README.md +5 -0
- package/changelog.d/20260909120000-velocious-testing.md +1 -0
- package/docs/cli.md +7 -1
- package/docs/generation-deployment-contract.md +9 -0
- package/eslint.config.js +8 -0
- package/package.json +3 -2
- package/src/cli.js +10 -2
- package/src/daemon.js +102 -12
- package/src/process-guardian.js +5 -1
- package/src/release-group.js +48 -1
- package/test/completion.test.js +18 -16
- package/test/config-examples.test.js +16 -17
- package/test/config-path.test.js +10 -11
- package/test/config-validation.test.js +163 -167
- package/test/control-protocol.test.js +75 -14
- package/test/daemon-bootstrap.test.js +104 -104
- package/test/daemon-runtime.test.js +17 -26
- package/test/doctor.test.js +51 -49
- package/test/event-log.test.js +13 -11
- package/test/guardian-client.test.js +160 -145
- package/test/health.test.js +6 -4
- package/test/logs.test.js +23 -17
- package/test/managed-process.test.js +96 -91
- package/test/owner-recovery.test.js +254 -239
- package/test/owner-replacement.test.js +228 -223
- package/test/package-metadata.test.js +48 -39
- package/test/port-allocator.test.js +13 -16
- package/test/predeploy-cleanup.test.js +12 -10
- package/test/process-memory.test.js +17 -15
- package/test/proxy.test.js +10 -8
- package/test/recover.test.js +30 -23
- package/test/release-group.test.js +16 -17
- package/test/release-retention.test.js +10 -8
- package/test/release-runtime-retention.test.js +31 -39
- package/test/rollbridge.test.js +388 -395
- package/test/shutdown-completion.test.js +51 -51
- package/test/state-store.test.js +10 -8
- package/test/system-ids.test.js +15 -13
package/test/health.test.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import http from "node:http"
|
|
5
|
-
import test from "
|
|
4
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
6
5
|
import {waitForHealth} from "../src/health.js"
|
|
7
6
|
|
|
7
|
+
describe("health", () => {
|
|
8
|
+
|
|
8
9
|
/**
|
|
9
10
|
* Starts a health server that records when it first receives a probe.
|
|
10
11
|
* @returns {Promise<{firstProbeDelay: () => number, port: number, close: () => Promise<void>}>} Server handle.
|
|
@@ -40,7 +41,7 @@ test("waitForHealth delays the first probe by startDelayMs", async () => {
|
|
|
40
41
|
port: server.port
|
|
41
42
|
})
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
expect({value: Boolean(server.firstProbeDelay() >= 180), context: `expected first probe to be delayed ~200ms, was ${server.firstProbeDelay()}ms`}).toMatchObject({value: true})
|
|
44
45
|
} finally {
|
|
45
46
|
await server.close()
|
|
46
47
|
}
|
|
@@ -56,8 +57,9 @@ test("waitForHealth probes immediately when startDelayMs is 0", async () => {
|
|
|
56
57
|
port: server.port
|
|
57
58
|
})
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
expect({value: Boolean(server.firstProbeDelay() < 150), context: `expected an immediate first probe, was ${server.firstProbeDelay()}ms`}).toMatchObject({value: true})
|
|
60
61
|
} finally {
|
|
61
62
|
await server.close()
|
|
62
63
|
}
|
|
63
64
|
})
|
|
65
|
+
})
|
package/test/logs.test.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import fs from "node:fs/promises"
|
|
5
4
|
import os from "node:os"
|
|
6
5
|
import path from "node:path"
|
|
7
|
-
import test from "
|
|
6
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
8
7
|
import {fileURLToPath} from "node:url"
|
|
9
8
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
10
9
|
import {normalizeConfig} from "../src/config.js"
|
|
11
10
|
import {formatLogSources, runCli} from "../src/cli.js"
|
|
12
11
|
|
|
12
|
+
describe("logs", () => {
|
|
13
|
+
|
|
13
14
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
14
15
|
const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
15
16
|
|
|
@@ -19,10 +20,10 @@ test("formatLogSources renders a section per process with timestamped lines", ()
|
|
|
19
20
|
{id: "beacon", logs: [], source: "service"}
|
|
20
21
|
], undefined)
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
expect(output).toMatch(/== web \[release v1 \(active\)\] ==/)
|
|
24
|
+
expect(output).toMatch(/2026-05-22T00:00:00\.000Z \[stdout\] listening/)
|
|
25
|
+
expect(output).toMatch(/== beacon \[service\] ==/)
|
|
26
|
+
expect(output).toMatch(/\(no recent output\)/)
|
|
26
27
|
})
|
|
27
28
|
|
|
28
29
|
test("formatLogSources filters to a single process id", () => {
|
|
@@ -33,16 +34,13 @@ test("formatLogSources filters to a single process id", () => {
|
|
|
33
34
|
|
|
34
35
|
const output = formatLogSources(sources, "web")
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
expect(output).toMatch(/== web /)
|
|
38
|
+
expect(output).not.toMatch(/beacon/)
|
|
38
39
|
})
|
|
39
40
|
|
|
40
41
|
test("formatLogSources reports when there are no processes or no match", () => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
formatLogSources([{id: "web", logs: [], source: "release v1 (active)"}], "missing"),
|
|
44
|
-
'No process found with id "missing".'
|
|
45
|
-
)
|
|
42
|
+
expect(formatLogSources([], undefined)).toBe("No managed processes.")
|
|
43
|
+
expect(formatLogSources([{id: "web", logs: [], source: "release v1 (active)"}], "missing")).toBe('No process found with id "missing".')
|
|
46
44
|
})
|
|
47
45
|
|
|
48
46
|
test("logs CLI prints captured output per managed process", async () => {
|
|
@@ -80,7 +78,7 @@ test("logs CLI prints captured output per managed process", async () => {
|
|
|
80
78
|
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
81
79
|
await runCli(["node", "rollbridge", "logs", "-c", path.join(root, "rollbridge.js")])
|
|
82
80
|
|
|
83
|
-
|
|
81
|
+
expect(lines.join("\n")).toMatch(/== web \[release v1 \(active\)\] ==/)
|
|
84
82
|
|
|
85
83
|
lines.length = 0
|
|
86
84
|
await runCli(["node", "rollbridge", "logs", "--json", "-c", path.join(root, "rollbridge.js")])
|
|
@@ -88,12 +86,20 @@ test("logs CLI prints captured output per managed process", async () => {
|
|
|
88
86
|
const parsed = JSON.parse(lines.join("\n"))
|
|
89
87
|
const web = parsed.find((/** @type {{id: string, logs: import("../src/managed-process.js").ManagedProcessLog[], source: string}} */ entry) => entry.id === "web")
|
|
90
88
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
if (!web) throw new Error("expected a web entry in the JSON output")
|
|
90
|
+
expect(web.source).toMatch(/release v1 \(active\)/)
|
|
91
|
+
expect(Array.isArray(web.logs)).toBeTruthy()
|
|
92
|
+
|
|
93
|
+
lines.length = 0
|
|
94
|
+
await runCli(["node", "rollbridge", "status", "--no-logs", "-c", path.join(root, "rollbridge.js")])
|
|
95
|
+
|
|
96
|
+
const status = JSON.parse(lines.join("\n"))
|
|
97
|
+
|
|
98
|
+
expect("logs" in status.releases[0].processes[0]).toBe(false)
|
|
94
99
|
} finally {
|
|
95
100
|
console.log = originalLog
|
|
96
101
|
await daemon.shutdown()
|
|
97
102
|
await fs.rm(root, {force: true, recursive: true})
|
|
98
103
|
}
|
|
99
104
|
})
|
|
105
|
+
})
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import fs from "node:fs"
|
|
5
4
|
import os from "node:os"
|
|
6
5
|
import path from "node:path"
|
|
7
|
-
import test from "
|
|
6
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
8
7
|
import {fileURLToPath} from "node:url"
|
|
9
8
|
import ManagedProcess from "../src/managed-process.js"
|
|
10
9
|
|
|
10
|
+
describe("managed-process", () => {
|
|
11
|
+
|
|
11
12
|
const crasherPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "crasher.js")
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -51,9 +52,9 @@ test("retains and reports only the configured number of recent output lines", ()
|
|
|
51
52
|
|
|
52
53
|
const {logs} = managed.status()
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
expect(logs.length).toBe(3)
|
|
56
|
+
expect(logs.map((entry) => entry.line)).toEqual(["c", "d", "e"])
|
|
57
|
+
expect(logs[0].stream).toBe("stdout")
|
|
57
58
|
})
|
|
58
59
|
|
|
59
60
|
test("keeps every output line when fewer than the retention limit are produced", () => {
|
|
@@ -63,17 +64,17 @@ test("keeps every output line when fewer than the retention limit are produced",
|
|
|
63
64
|
|
|
64
65
|
const {logs} = managed.status()
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
expect(logs.map((entry) => entry.line)).toEqual(["one", "two"])
|
|
67
68
|
})
|
|
68
69
|
|
|
69
70
|
test("reassembles output lines split across stream chunks", () => {
|
|
70
71
|
const managed = buildProcess(50)
|
|
71
72
|
|
|
72
73
|
managed.appendLog("stdout", "8191:x")
|
|
73
|
-
|
|
74
|
+
expect(managed.status().logs).toEqual([])
|
|
74
75
|
|
|
75
76
|
managed.appendLog("stdout", "xx\n")
|
|
76
|
-
|
|
77
|
+
expect(managed.status().logs.map((entry) => entry.line)).toEqual(["8191:xxx"])
|
|
77
78
|
})
|
|
78
79
|
|
|
79
80
|
test("bounds an output fragment that never terminates", () => {
|
|
@@ -82,8 +83,8 @@ test("bounds an output fragment that never terminates", () => {
|
|
|
82
83
|
|
|
83
84
|
managed.appendLog("stdout", fragment)
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
expect(managed.status().logs.length).toBe(15)
|
|
87
|
+
expect(managed.outputBuffers.stdout.length).toBe(64 * 1024)
|
|
87
88
|
})
|
|
88
89
|
|
|
89
90
|
test("emits each output line after retaining it", () => {
|
|
@@ -95,7 +96,7 @@ test("emits each output line after retaining it", () => {
|
|
|
95
96
|
})
|
|
96
97
|
managed.appendLog("stdout", "ready\n")
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
expect(observed).toEqual({
|
|
99
100
|
entry: managed.status().logs[0],
|
|
100
101
|
retained: managed.status().logs
|
|
101
102
|
})
|
|
@@ -104,10 +105,10 @@ test("emits each output line after retaining it", () => {
|
|
|
104
105
|
test("reports zeroed restart and uptime fields before the process starts", () => {
|
|
105
106
|
const status = buildProcess(50).status()
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
expect(status.restarts).toBe(0)
|
|
109
|
+
expect(status.startedAt).toBe(undefined)
|
|
110
|
+
expect(status.uptimeMs).toBe(undefined)
|
|
111
|
+
expect(status.state).toBe("stopped")
|
|
111
112
|
})
|
|
112
113
|
|
|
113
114
|
test("counts automatic restarts and reports startedAt and uptime while running", async () => {
|
|
@@ -128,15 +129,15 @@ test("counts automatic restarts and reports startedAt and uptime while running",
|
|
|
128
129
|
|
|
129
130
|
const initial = managed.status()
|
|
130
131
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
expect(initial.restarts).toBe(0)
|
|
133
|
+
expect(initial.state).toBe("running")
|
|
134
|
+
expect(typeof initial.startedAt).toBe("string")
|
|
135
|
+
expect(typeof initial.uptimeMs === "number" && initial.uptimeMs >= 0).toBeTruthy()
|
|
135
136
|
|
|
136
137
|
// The fixture exits non-zero ~40ms after each start, so it keeps auto-restarting.
|
|
137
138
|
await waitFor(() => managed.status().restarts >= 2)
|
|
138
139
|
|
|
139
|
-
|
|
140
|
+
expect(managed.status().restarts >= 2).toBeTruthy()
|
|
140
141
|
} finally {
|
|
141
142
|
await managed.stop()
|
|
142
143
|
}
|
|
@@ -162,7 +163,7 @@ test("a queued auto-restart timer is unref'd so it can't keep the process alive"
|
|
|
162
163
|
// ref'd timer would respawn forever and block process exit, so the queued timer must be unref'd.
|
|
163
164
|
await waitFor(() => managed.restartTimer !== undefined)
|
|
164
165
|
|
|
165
|
-
|
|
166
|
+
expect(managed.restartTimer?.hasRef()).toBe(false)
|
|
166
167
|
} finally {
|
|
167
168
|
await managed.stop()
|
|
168
169
|
}
|
|
@@ -194,12 +195,12 @@ test("records the start reason, marking crash auto-restarts", async () => {
|
|
|
194
195
|
try {
|
|
195
196
|
await managed.start()
|
|
196
197
|
|
|
197
|
-
|
|
198
|
+
expect(managed.status().lastStartReason).toBe("deploy")
|
|
198
199
|
|
|
199
200
|
// The fixture crashes ~40ms after each start, so it auto-restarts with reason "crash".
|
|
200
201
|
await waitFor(() => managed.status().restarts >= 1)
|
|
201
202
|
|
|
202
|
-
|
|
203
|
+
expect(managed.status().lastStartReason).toBe("crash")
|
|
203
204
|
} finally {
|
|
204
205
|
await managed.stop()
|
|
205
206
|
}
|
|
@@ -212,7 +213,7 @@ test("records the manual start reason", async () => {
|
|
|
212
213
|
try {
|
|
213
214
|
await managed.start("manual")
|
|
214
215
|
|
|
215
|
-
|
|
216
|
+
expect(managed.status().lastStartReason).toBe("manual")
|
|
216
217
|
} finally {
|
|
217
218
|
await managed.stop()
|
|
218
219
|
}
|
|
@@ -232,8 +233,8 @@ test("a later stop cancels a start queued behind an in-flight stop", async () =>
|
|
|
232
233
|
finishStop()
|
|
233
234
|
await Promise.all([queuedStart, finalStop])
|
|
234
235
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
236
|
+
expect(managed.status().pid).toBe(undefined)
|
|
237
|
+
expect(managed.status().state).toBe("stopped")
|
|
237
238
|
} finally {
|
|
238
239
|
await managed.stop()
|
|
239
240
|
}
|
|
@@ -253,8 +254,8 @@ test("does not record a start reason when the spawn fails", async () => {
|
|
|
253
254
|
})
|
|
254
255
|
|
|
255
256
|
// The cwd does not exist, so the spawn fails before the process ever runs.
|
|
256
|
-
await
|
|
257
|
-
|
|
257
|
+
await expect(managed.start("manual")).rejects.toThrow()
|
|
258
|
+
expect(managed.status().lastStartReason).toBe(undefined)
|
|
258
259
|
})
|
|
259
260
|
|
|
260
261
|
/**
|
|
@@ -298,9 +299,9 @@ test("runs quiet and drain lifecycle hooks before stopping", async () => {
|
|
|
298
299
|
await managed.start()
|
|
299
300
|
await managed.stop()
|
|
300
301
|
|
|
301
|
-
|
|
302
|
+
expect(managed.status().state).toBe("stopped")
|
|
302
303
|
// quietCommand ran, then drainCommand, then the worker was stopped via stopSignal.
|
|
303
|
-
|
|
304
|
+
expect(fs.readFileSync(logPath, "utf8").trim().split("\n")).toEqual(["quiet", "drain"])
|
|
304
305
|
} finally {
|
|
305
306
|
await managed.stop()
|
|
306
307
|
fs.rmSync(dir, {force: true, recursive: true})
|
|
@@ -338,10 +339,10 @@ test("a configured stopCommand is used instead of the stop signal", async () =>
|
|
|
338
339
|
await managed.start()
|
|
339
340
|
await managed.stop()
|
|
340
341
|
|
|
341
|
-
|
|
342
|
-
|
|
342
|
+
expect(managed.status().state).toBe("stopped")
|
|
343
|
+
expect(fs.readFileSync(logPath, "utf8").trim().split("\n")).toEqual(["stop"])
|
|
343
344
|
// The stop signal is replaced by the stop command (only a SIGKILL fallback may be sent).
|
|
344
|
-
|
|
345
|
+
expect({value: Boolean(!signals.includes("SIGTERM")), context: `expected no stopSignal, got ${signals.join(",")}`}).toMatchObject({value: true})
|
|
345
346
|
} finally {
|
|
346
347
|
await managed.stop()
|
|
347
348
|
fs.rmSync(dir, {force: true, recursive: true})
|
|
@@ -376,7 +377,7 @@ test("stopCommand receives the retained process group id after the shell exits",
|
|
|
376
377
|
|
|
377
378
|
await managed.stop()
|
|
378
379
|
|
|
379
|
-
|
|
380
|
+
expect(fs.readFileSync(pidPath, "utf8").trim()).toBe(String(pgid))
|
|
380
381
|
} finally {
|
|
381
382
|
await managed.stop()
|
|
382
383
|
fs.rmSync(dir, {force: true, recursive: true})
|
|
@@ -404,8 +405,8 @@ test("a failing lifecycle hook is logged but does not fail the stop", async () =
|
|
|
404
405
|
await managed.start()
|
|
405
406
|
await managed.stop()
|
|
406
407
|
|
|
407
|
-
|
|
408
|
-
|
|
408
|
+
expect(managed.status().state).toBe("stopped")
|
|
409
|
+
expect({value: Boolean(messages.includes("quiet command exited non-zero")), context: `expected a non-zero hook log, got ${messages.join(",")}`}).toMatchObject({value: true})
|
|
409
410
|
} finally {
|
|
410
411
|
await managed.stop()
|
|
411
412
|
}
|
|
@@ -433,9 +434,9 @@ test("a hanging lifecycle hook is bounded so stop still completes", async () =>
|
|
|
433
434
|
|
|
434
435
|
await managed.stop()
|
|
435
436
|
|
|
436
|
-
|
|
437
|
+
expect(managed.status().state).toBe("stopped")
|
|
437
438
|
// The hung quietCommand is killed at stopTimeoutMs rather than blocking stop indefinitely.
|
|
438
|
-
|
|
439
|
+
expect(Date.now() - startedAt < 5000).toBe(true)
|
|
439
440
|
} finally {
|
|
440
441
|
await managed.stop()
|
|
441
442
|
}
|
|
@@ -449,7 +450,7 @@ test("activateStrict runs the configured activation command once per call and re
|
|
|
449
450
|
await managed.start()
|
|
450
451
|
const pid = managed.pid
|
|
451
452
|
|
|
452
|
-
|
|
453
|
+
expect(pid).toBeTruthy()
|
|
453
454
|
managed.lifecycle = {activateCommand: "jobs activate", activateTimeoutMs: 60000, drainTimeoutMs: 0}
|
|
454
455
|
managed.runHook = async (command, timeoutMs, label, hookPid) => {
|
|
455
456
|
commands.push({command, label, pid: hookPid, timeoutMs})
|
|
@@ -457,10 +458,10 @@ test("activateStrict runs the configured activation command once per call and re
|
|
|
457
458
|
}
|
|
458
459
|
|
|
459
460
|
await managed.activateStrict()
|
|
460
|
-
|
|
461
|
+
expect(commands).toEqual([{command: "jobs activate", label: "activate command", pid, timeoutMs: 60000}])
|
|
461
462
|
|
|
462
463
|
managed.runHook = async () => new Error("activation rejected")
|
|
463
|
-
await
|
|
464
|
+
await expect(managed.activateStrict()).rejects.toThrow(/activation rejected/)
|
|
464
465
|
} finally {
|
|
465
466
|
await managed.stop()
|
|
466
467
|
}
|
|
@@ -482,8 +483,8 @@ test("activateStrict rejects when the activated process is replaced while its ho
|
|
|
482
483
|
return undefined
|
|
483
484
|
}
|
|
484
485
|
|
|
485
|
-
await
|
|
486
|
-
|
|
486
|
+
await expect(managed.activateStrict()).rejects.toThrow(/exited before activation completed/)
|
|
487
|
+
expect(managed.lifecycleRole).toBe("candidate")
|
|
487
488
|
} finally {
|
|
488
489
|
managed.child = child
|
|
489
490
|
managed.pid = pid
|
|
@@ -501,8 +502,8 @@ test("activateStrict rejects an activation request when its process is not runni
|
|
|
501
502
|
return undefined
|
|
502
503
|
}
|
|
503
504
|
|
|
504
|
-
await
|
|
505
|
-
|
|
505
|
+
await expect(managed.activateStrict()).rejects.toThrow(/is not running for activation/)
|
|
506
|
+
expect(hookRan).toBe(false)
|
|
506
507
|
})
|
|
507
508
|
|
|
508
509
|
test("reactivateStrict restores a retained quiesced process only after activation succeeds", async () => {
|
|
@@ -519,14 +520,14 @@ test("reactivateStrict restores a retained quiesced process only after activatio
|
|
|
519
520
|
try {
|
|
520
521
|
await managed.start()
|
|
521
522
|
await managed.quiesceStrict()
|
|
522
|
-
await
|
|
523
|
-
|
|
524
|
-
|
|
523
|
+
await expect(managed.reactivateStrict()).rejects.toThrow(/restoration rejected/)
|
|
524
|
+
expect(managed.status().state).toBe("quiesced")
|
|
525
|
+
expect(managed.status().lifecycleRole).toBe("retired")
|
|
525
526
|
|
|
526
527
|
await managed.reactivateStrict()
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
528
|
+
expect(managed.status().state).toBe("running")
|
|
529
|
+
expect(managed.status().lifecycleRole).toBe("active")
|
|
530
|
+
expect(hooks).toEqual(["quiet command", "activate command", "activate command"])
|
|
530
531
|
} finally {
|
|
531
532
|
await managed.stop()
|
|
532
533
|
}
|
|
@@ -543,15 +544,15 @@ test("reactivateStrict retries a failed active-role startup against the retained
|
|
|
543
544
|
}
|
|
544
545
|
|
|
545
546
|
try {
|
|
546
|
-
await
|
|
547
|
+
await expect(managed.start("deploy", "active")).rejects.toThrow(/startup activation raced readiness/)
|
|
547
548
|
const failed = managed.status()
|
|
548
549
|
|
|
549
|
-
|
|
550
|
-
|
|
550
|
+
expect(failed.state).toBe("failed")
|
|
551
|
+
expect(failed.pid).toBeTruthy()
|
|
551
552
|
await managed.reactivateStrict()
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
553
|
+
expect(managed.status().state).toBe("running")
|
|
554
|
+
expect(managed.status().lifecycleRole).toBe("active")
|
|
555
|
+
expect(managed.status().pid).toBe(failed.pid)
|
|
555
556
|
} finally {
|
|
556
557
|
await managed.stop()
|
|
557
558
|
}
|
|
@@ -579,19 +580,22 @@ test("quiesce waits for active-role restoration before retiring a restarted proc
|
|
|
579
580
|
}
|
|
580
581
|
return undefined
|
|
581
582
|
}
|
|
582
|
-
const start =
|
|
583
|
+
const start = (async () => {
|
|
584
|
+
await expect(managed.start("crash", "active")).rejects.toThrow(/quiesced before lifecycle role active was restored/)
|
|
585
|
+
})()
|
|
583
586
|
|
|
584
587
|
try {
|
|
585
588
|
await activationStarted
|
|
586
589
|
const quiesce = managed.quiesceStrict()
|
|
587
590
|
|
|
588
591
|
await Promise.resolve()
|
|
589
|
-
|
|
592
|
+
// Retirement must not race ahead of role restoration.
|
|
593
|
+
expect(hooks).toEqual(["activate:start"])
|
|
590
594
|
allowActivation()
|
|
591
595
|
await Promise.all([start, quiesce])
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
596
|
+
expect(hooks).toEqual(["activate:start", "activate:end", "quiet"])
|
|
597
|
+
expect(managed.status().state).toBe("quiesced")
|
|
598
|
+
expect(managed.lifecycleRole).toBe("retired")
|
|
595
599
|
} finally {
|
|
596
600
|
allowActivation()
|
|
597
601
|
await start.catch(() => {})
|
|
@@ -630,8 +634,8 @@ test("sends the configured stopSignal as the graceful stop signal", async () =>
|
|
|
630
634
|
await managed.stop()
|
|
631
635
|
|
|
632
636
|
// The graceful stop reaches the ready descendant and its shell leader without SIGKILL.
|
|
633
|
-
|
|
634
|
-
|
|
637
|
+
expect(signals).toEqual(["SIGINT", "SIGINT"])
|
|
638
|
+
expect(managed.status().state).toBe("stopped")
|
|
635
639
|
} finally {
|
|
636
640
|
await managed.stop()
|
|
637
641
|
fs.rmSync(dir, {force: true, recursive: true})
|
|
@@ -664,8 +668,8 @@ test("indefinite stop waits for the process to exit without SIGKILL", async () =
|
|
|
664
668
|
await managed.start()
|
|
665
669
|
await managed.stop()
|
|
666
670
|
|
|
667
|
-
|
|
668
|
-
|
|
671
|
+
expect(managed.status().state).toBe("stopped")
|
|
672
|
+
expect(signals).toEqual(["SIGTERM", "SIGTERM"])
|
|
669
673
|
} finally {
|
|
670
674
|
await managed.stop()
|
|
671
675
|
}
|
|
@@ -704,9 +708,9 @@ test("stop waits for process group descendants after the detached shell exits",
|
|
|
704
708
|
|
|
705
709
|
const elapsedMs = Date.now() - startedAt
|
|
706
710
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
711
|
+
expect({value: Boolean(elapsedMs >= 250), context: `stop resolved after only ${elapsedMs}ms`}).toMatchObject({value: true})
|
|
712
|
+
expect({value: Boolean(elapsedMs < 1500), context: `stop took ${elapsedMs}ms`}).toMatchObject({value: true})
|
|
713
|
+
expect(fs.readFileSync(latePath, "utf8")).toBe("late")
|
|
710
714
|
} finally {
|
|
711
715
|
await managed.stop()
|
|
712
716
|
fs.rmSync(dir, {force: true, recursive: true})
|
|
@@ -742,7 +746,7 @@ test("stop does not return while a gracefully stopped descendant remains unreape
|
|
|
742
746
|
|
|
743
747
|
await managed.stop()
|
|
744
748
|
|
|
745
|
-
|
|
749
|
+
await expect(Promise.resolve().then(() => process.kill(childPid, 0))).rejects.toMatchObject({code: "ESRCH"})
|
|
746
750
|
} finally {
|
|
747
751
|
await managed.stop()
|
|
748
752
|
fs.rmSync(dir, {force: true, recursive: true})
|
|
@@ -773,7 +777,7 @@ test("descendant reaping and leader shutdown share one graceful deadline", async
|
|
|
773
777
|
try {
|
|
774
778
|
await managed.stop({timeoutMs: 150})
|
|
775
779
|
|
|
776
|
-
|
|
780
|
+
expect(calls.slice(0, 2)).toEqual([
|
|
777
781
|
{deadline: 1150, signal: "SIGTERM"},
|
|
778
782
|
{deadline: 1150}
|
|
779
783
|
])
|
|
@@ -789,9 +793,9 @@ test("a memory restart respawns and is counted when the supervisor still wants t
|
|
|
789
793
|
await managed.start()
|
|
790
794
|
await managed.restartForMemory()
|
|
791
795
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
796
|
+
expect(managed.status().state).toBe("running")
|
|
797
|
+
expect(managed.memoryRestarts).toBe(1)
|
|
798
|
+
expect(managed.status().lastStartReason).toBe("memory")
|
|
795
799
|
} finally {
|
|
796
800
|
await managed.stop()
|
|
797
801
|
}
|
|
@@ -803,14 +807,14 @@ test("a memory restart does not respawn when shouldRestart is false", async () =
|
|
|
803
807
|
|
|
804
808
|
try {
|
|
805
809
|
await managed.start()
|
|
806
|
-
|
|
810
|
+
expect(managed.status().state).toBe("running")
|
|
807
811
|
|
|
808
812
|
// The supervisor (e.g. daemon shutdown or a draining release) no longer wants it running.
|
|
809
813
|
allowRestart = false
|
|
810
814
|
await managed.restartForMemory()
|
|
811
815
|
|
|
812
|
-
|
|
813
|
-
|
|
816
|
+
expect(managed.status().state).toBe("stopped")
|
|
817
|
+
expect(managed.memoryRestarts).toBe(0)
|
|
814
818
|
} finally {
|
|
815
819
|
await managed.stop()
|
|
816
820
|
}
|
|
@@ -826,8 +830,8 @@ test("does not auto-restart when the restart policy is disabled (maxRestarts: 0)
|
|
|
826
830
|
await waitFor(() => managed.status().state === "failed")
|
|
827
831
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
828
832
|
|
|
829
|
-
|
|
830
|
-
|
|
833
|
+
expect(managed.status().restarts).toBe(0)
|
|
834
|
+
expect(managed.status().state).toBe("failed")
|
|
831
835
|
} finally {
|
|
832
836
|
await managed.stop()
|
|
833
837
|
}
|
|
@@ -847,9 +851,9 @@ test("stops auto-restarting once maxRestarts within the window is reached", asyn
|
|
|
847
851
|
await waitFor(() => managed.status().restarts === 2 && managed.status().state === "failed")
|
|
848
852
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
849
853
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
854
|
+
expect(managed.status().restarts).toBe(2)
|
|
855
|
+
expect(managed.status().state).toBe("failed")
|
|
856
|
+
expect(events.find((event) => event.message === "restart limit reached")?.data).toEqual({
|
|
853
857
|
id: "crasher",
|
|
854
858
|
maxRestarts: 2,
|
|
855
859
|
windowMs: 60000
|
|
@@ -863,24 +867,24 @@ test("applies exponential backoff to restart delays, capped by maxDelayMs", () =
|
|
|
863
867
|
const capped = buildCrasher({backoffFactor: 2, maxDelayMs: 500, maxRestarts: undefined, windowMs: 0})
|
|
864
868
|
|
|
865
869
|
// restartDelayMs (10) * 2 ** attempt, capped at 500.
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
870
|
+
expect(capped.restartDelayFor(0)).toBe(10)
|
|
871
|
+
expect(capped.restartDelayFor(1)).toBe(20)
|
|
872
|
+
expect(capped.restartDelayFor(2)).toBe(40)
|
|
873
|
+
expect(capped.restartDelayFor(6)).toBe(500) // 10 * 64 = 640, capped to 500
|
|
874
|
+
expect(capped.restartDelayFor(7)).toBe(500)
|
|
871
875
|
|
|
872
876
|
// maxDelayMs: 0 means no cap.
|
|
873
877
|
const uncapped = buildCrasher({backoffFactor: 3, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0})
|
|
874
878
|
|
|
875
|
-
|
|
876
|
-
|
|
879
|
+
expect(uncapped.restartDelayFor(0)).toBe(10)
|
|
880
|
+
expect(uncapped.restartDelayFor(2)).toBe(90)
|
|
877
881
|
})
|
|
878
882
|
|
|
879
883
|
test("the unlimited constant-delay fast path still applies maxDelayMs", () => {
|
|
880
884
|
// restartDelayMs (10) above maxDelayMs (5), with no backoff and unlimited restarts.
|
|
881
885
|
const managed = buildCrasher({backoffFactor: 1, maxDelayMs: 5, maxRestarts: undefined, windowMs: 0})
|
|
882
886
|
|
|
883
|
-
|
|
887
|
+
expect(managed.restartDelayFor(0)).toBe(5)
|
|
884
888
|
|
|
885
889
|
/** @type {number | undefined} */
|
|
886
890
|
let queued
|
|
@@ -888,5 +892,6 @@ test("the unlimited constant-delay fast path still applies maxDelayMs", () => {
|
|
|
888
892
|
managed.queueRestart = (delayMs) => { queued = delayMs }
|
|
889
893
|
managed.scheduleRestart()
|
|
890
894
|
|
|
891
|
-
|
|
895
|
+
expect(queued).toBe(5)
|
|
896
|
+
})
|
|
892
897
|
})
|