rollbridge 0.1.21 → 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 +1 -1
- package/src/cli.js +28 -3
- package/src/daemon.js +10 -1
- package/src/managed-process.js +67 -13
- package/test/daemon-bootstrap.test.js +198 -19
- package/test/fixtures/owned-child.js +19 -0
- package/test/managed-process.test.js +90 -13
- package/test/release-runtime-retention.test.js +10 -7
- package/test/shutdown-completion.test.js +36 -1
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -65,9 +65,19 @@ export async function runCli(argv) {
|
|
|
65
65
|
} else {
|
|
66
66
|
await daemon.exposeControl()
|
|
67
67
|
}
|
|
68
|
-
} catch {
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const failure = error instanceof Error ? error : String(error)
|
|
70
|
+
|
|
71
|
+
daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error", ...errorLogData(failure)})
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await daemon.shutdown()
|
|
75
|
+
} catch (shutdownError) {
|
|
76
|
+
const shutdownFailure = shutdownError instanceof Error ? shutdownError : String(shutdownError)
|
|
77
|
+
|
|
78
|
+
daemon.logger("bootstrap shutdown failed", {releaseId: bootstrap.releaseId, status: "error", ...errorLogData(shutdownFailure)})
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
process.exitCode = 1
|
|
72
82
|
return
|
|
73
83
|
}
|
|
@@ -960,3 +970,18 @@ function isMissingDaemonError(error) {
|
|
|
960
970
|
|
|
961
971
|
return error.code === "ENOENT" || error.code === "ECONNREFUSED"
|
|
962
972
|
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Converts any thrown value into JSON-safe diagnostics without losing an Error stack.
|
|
976
|
+
* @param {Error | string} error - Thrown value.
|
|
977
|
+
* @returns {{error: string, stack: string}} Safe structured log fields.
|
|
978
|
+
*/
|
|
979
|
+
function errorLogData(error) {
|
|
980
|
+
if (error instanceof Error) {
|
|
981
|
+
return {error: error.message, stack: error.stack || `${error.name}: ${error.message}`}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const message = String(error)
|
|
985
|
+
|
|
986
|
+
return {error: message, stack: message}
|
|
987
|
+
}
|
package/src/daemon.js
CHANGED
|
@@ -56,6 +56,7 @@ export default class RollbridgeDaemon {
|
|
|
56
56
|
this.proxy = httpProxy.createProxyServer({ws: true, xfwd: true})
|
|
57
57
|
this.proxyServer = /** @type {http.Server | undefined} */ (undefined)
|
|
58
58
|
this.controlServer = /** @type {net.Server | undefined} */ (undefined)
|
|
59
|
+
this.controlSocketOwned = false
|
|
59
60
|
this.controlSockets = /** @type {Set<net.Socket>} */ (new Set())
|
|
60
61
|
this.proxyPort = /** @type {number | undefined} */ (undefined)
|
|
61
62
|
this.stopping = false
|
|
@@ -63,6 +64,7 @@ export default class RollbridgeDaemon {
|
|
|
63
64
|
this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
|
|
64
65
|
this.persistenceEnabled = false
|
|
65
66
|
this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
|
|
67
|
+
this.stateCleanupEnabled = false
|
|
66
68
|
this.shutdownPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
67
69
|
this.retirementPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
68
70
|
this.controlClosePromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
@@ -128,6 +130,7 @@ export default class RollbridgeDaemon {
|
|
|
128
130
|
await new Promise((resolve, reject) => {
|
|
129
131
|
server.once("error", reject)
|
|
130
132
|
server.listen(this.config.control.path, () => {
|
|
133
|
+
this.controlSocketOwned = true
|
|
131
134
|
this.logger("control socket listening", {path: this.config.control.path})
|
|
132
135
|
resolve(undefined)
|
|
133
136
|
})
|
|
@@ -741,6 +744,7 @@ export default class RollbridgeDaemon {
|
|
|
741
744
|
startStatePersistence() {
|
|
742
745
|
if (!this.statePath) return
|
|
743
746
|
|
|
747
|
+
this.stateCleanupEnabled = true
|
|
744
748
|
this.persistenceEnabled = true
|
|
745
749
|
this.persistState()
|
|
746
750
|
this.persistTimer = setInterval(() => this.persistState(), STATE_PERSIST_INTERVAL_MS)
|
|
@@ -780,6 +784,7 @@ export default class RollbridgeDaemon {
|
|
|
780
784
|
async reportOrphans() {
|
|
781
785
|
if (!this.statePath) return
|
|
782
786
|
|
|
787
|
+
this.stateCleanupEnabled = true
|
|
783
788
|
const orphans = liveProcesses(await readState(this.statePath))
|
|
784
789
|
|
|
785
790
|
// Keep them for status() so `rollbridge status` reflects still-running children after a
|
|
@@ -832,6 +837,7 @@ export default class RollbridgeDaemon {
|
|
|
832
837
|
}
|
|
833
838
|
this.persistenceEnabled = false
|
|
834
839
|
if (this.pendingWrite) await this.pendingWrite
|
|
840
|
+
this.stateCleanupEnabled = false
|
|
835
841
|
this.controlClosePromise = this.closeServer(this.controlServer)
|
|
836
842
|
for (const socket of this.controlSockets) if (socket !== completionSocket) socket.destroy()
|
|
837
843
|
await Promise.all([
|
|
@@ -888,7 +894,7 @@ export default class RollbridgeDaemon {
|
|
|
888
894
|
// new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
|
|
889
895
|
// orphans are not owned by this daemon, so retain their records until they are confirmed gone.
|
|
890
896
|
await captureShutdownError(cleanupErrors, "persistent state cleanup", async () => {
|
|
891
|
-
if (!this.statePath) return
|
|
897
|
+
if (!this.statePath || !this.stateCleanupEnabled) return
|
|
892
898
|
if (this.pendingWrite) await this.pendingWrite
|
|
893
899
|
const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
|
|
894
900
|
|
|
@@ -910,7 +916,10 @@ export default class RollbridgeDaemon {
|
|
|
910
916
|
|
|
911
917
|
/** @returns {Promise<void>} Removes the configured control socket path. */
|
|
912
918
|
async removeControlSocket() {
|
|
919
|
+
if (!this.controlSocketOwned) return
|
|
920
|
+
|
|
913
921
|
await fs.rm(this.config.control.path, {force: true})
|
|
922
|
+
this.controlSocketOwned = false
|
|
914
923
|
}
|
|
915
924
|
|
|
916
925
|
/**
|
package/src/managed-process.js
CHANGED
|
@@ -62,6 +62,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
62
62
|
this.memoryWarned = false
|
|
63
63
|
this.startedAtMs = /** @type {number | undefined} */ (undefined)
|
|
64
64
|
this.intentionalStop = false
|
|
65
|
+
this.intentionalStopSignal = /** @type {ProcessExitSignal | undefined} */ (undefined)
|
|
65
66
|
this.quiescePromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
66
67
|
this.restartTimer = undefined
|
|
67
68
|
this.child = undefined
|
|
@@ -79,6 +80,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
79
80
|
if (this.child) return
|
|
80
81
|
|
|
81
82
|
this.intentionalStop = false
|
|
83
|
+
this.intentionalStopSignal = undefined
|
|
82
84
|
this.quiescePromise = undefined
|
|
83
85
|
this.exitCode = undefined
|
|
84
86
|
this.exitSignal = undefined
|
|
@@ -167,8 +169,8 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
167
169
|
onExit(code, signal) {
|
|
168
170
|
const wasIntentional = this.intentionalStop
|
|
169
171
|
|
|
170
|
-
this.exitCode = code
|
|
171
|
-
this.exitSignal = signal
|
|
172
|
+
this.exitCode = this.intentionalStopSignal ? null : code
|
|
173
|
+
this.exitSignal = signal ?? this.intentionalStopSignal
|
|
172
174
|
this.child = undefined
|
|
173
175
|
this.pid = undefined
|
|
174
176
|
this.exitPromise = undefined
|
|
@@ -362,15 +364,24 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
362
364
|
|
|
363
365
|
// 3. Stop whatever is still running, then SIGKILL if it outlasts the graceful window.
|
|
364
366
|
if (this.processGroupExists(pgid)) {
|
|
365
|
-
if (stopCommand) await this.runHook(stopCommand, hookTimeoutMs, "stop command", pgid)
|
|
366
|
-
else this.killProcessGroup(this.stopSignal, pgid)
|
|
367
|
-
|
|
368
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 {
|
|
374
|
+
this.intentionalStopSignal = /** @type {ProcessExitSignal} */ (this.stopSignal)
|
|
375
|
+
gracefulDeadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
|
|
376
|
+
await this.signalProcessGroup(this.stopSignal, pgid, gracefulDeadline)
|
|
377
|
+
}
|
|
369
378
|
|
|
370
|
-
if (!(await this.waitForProcessGroupExit(pgid,
|
|
379
|
+
if (!(await this.waitForProcessGroupExit(pgid, gracefulDeadline))) {
|
|
371
380
|
this.logger("process stop timed out; sending SIGKILL", {id: this.id, pid: pgid})
|
|
372
|
-
|
|
373
|
-
|
|
381
|
+
const killDeadline = Date.now() + 5000
|
|
382
|
+
|
|
383
|
+
await this.signalProcessGroup("SIGKILL", pgid, killDeadline)
|
|
384
|
+
await this.waitForProcessGroupExit(pgid, killDeadline)
|
|
374
385
|
}
|
|
375
386
|
}
|
|
376
387
|
|
|
@@ -489,6 +500,49 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
489
500
|
}
|
|
490
501
|
}
|
|
491
502
|
|
|
503
|
+
/**
|
|
504
|
+
* Signals descendants before their shell leader so the leader can reap them before it exits.
|
|
505
|
+
* Falls back to the portable group signal when procfs cannot identify group members.
|
|
506
|
+
* @param {string} signal - Signal name.
|
|
507
|
+
* @param {number} pgid - Process group id.
|
|
508
|
+
* @param {number | undefined} deadline - Absolute graceful deadline, or undefined to wait indefinitely.
|
|
509
|
+
* @returns {Promise<void>} Resolves after the leader has been signalled.
|
|
510
|
+
*/
|
|
511
|
+
async signalProcessGroup(signal, pgid, deadline) {
|
|
512
|
+
const members = processGroupMembers(pgid)
|
|
513
|
+
const descendants = members.filter((member) => member.pid !== pgid)
|
|
514
|
+
|
|
515
|
+
if (descendants.length === 0) {
|
|
516
|
+
this.killProcessGroup(signal, pgid)
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
for (const descendant of descendants) this.killProcess(descendant.pid, signal)
|
|
521
|
+
while (processGroupMembers(pgid).some((member) => member.pid !== pgid)) {
|
|
522
|
+
if (deadline !== undefined && Date.now() >= deadline) break
|
|
523
|
+
const waitMs = deadline === undefined ? 25 : Math.min(25, deadline - Date.now())
|
|
524
|
+
|
|
525
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
this.killProcess(pgid, signal)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Signals one verified member of the owned process group.
|
|
533
|
+
* @param {number} pid - Process id.
|
|
534
|
+
* @param {string} signal - Signal name.
|
|
535
|
+
* @returns {void}
|
|
536
|
+
*/
|
|
537
|
+
killProcess(pid, signal) {
|
|
538
|
+
try {
|
|
539
|
+
process.kill(pid, signal)
|
|
540
|
+
} catch (error) {
|
|
541
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return
|
|
542
|
+
throw error
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
492
546
|
/**
|
|
493
547
|
* @param {number} pgid - Process group id.
|
|
494
548
|
* @returns {boolean} True until the process group no longer exists.
|
|
@@ -510,15 +564,15 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
510
564
|
|
|
511
565
|
/**
|
|
512
566
|
* @param {number} pgid - Process group id.
|
|
513
|
-
* @param {
|
|
567
|
+
* @param {number | undefined} deadline - Absolute deadline, or undefined to wait indefinitely.
|
|
514
568
|
* @returns {Promise<boolean>} True once the process group no longer exists.
|
|
515
569
|
*/
|
|
516
|
-
async waitForProcessGroupExit(pgid,
|
|
517
|
-
const deadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
|
|
518
|
-
|
|
570
|
+
async waitForProcessGroupExit(pgid, deadline) {
|
|
519
571
|
while (this.processGroupExists(pgid)) {
|
|
520
572
|
if (deadline !== undefined && Date.now() >= deadline) return false
|
|
521
|
-
|
|
573
|
+
const waitMs = deadline === undefined ? 10 : Math.min(10, deadline - Date.now())
|
|
574
|
+
|
|
575
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
|
522
576
|
}
|
|
523
577
|
|
|
524
578
|
return true
|
|
@@ -4,6 +4,7 @@ import assert from "node:assert/strict"
|
|
|
4
4
|
import {spawn} from "node:child_process"
|
|
5
5
|
import {once} from "node:events"
|
|
6
6
|
import fs from "node:fs/promises"
|
|
7
|
+
import net from "node:net"
|
|
7
8
|
import os from "node:os"
|
|
8
9
|
import path from "node:path"
|
|
9
10
|
import test from "node:test"
|
|
@@ -14,9 +15,12 @@ import {isProcessAlive, liveProcesses, readState, writeState} from "../src/state
|
|
|
14
15
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
15
16
|
const binPath = path.join(currentDir, "..", "bin", "rollbridge")
|
|
16
17
|
const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
18
|
+
const ownedChildPath = path.join(currentDir, "fixtures", "owned-child.js")
|
|
17
19
|
const firstAttestation = `sha256:${"a".repeat(64)}`
|
|
18
20
|
const secondAttestation = `sha256:${"b".repeat(64)}`
|
|
19
21
|
|
|
22
|
+
/** @typedef {{data?: Record<string, import("../src/json.js").JsonValue>, message?: string}} StructuredRecord */
|
|
23
|
+
|
|
20
24
|
test("daemon bootstrap requires complete, safe, absolute inputs before binding listeners", async (t) => {
|
|
21
25
|
const cases = [
|
|
22
26
|
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1"], message: /must be provided together/},
|
|
@@ -86,11 +90,12 @@ test("daemon bootstrap activates the exact release through the foreground daemon
|
|
|
86
90
|
})
|
|
87
91
|
|
|
88
92
|
test("failed takeover bootstrap preserves the previously accepted owner", async () => {
|
|
89
|
-
const fixture = await createFixture()
|
|
93
|
+
const fixture = await createFixture({persistState: true})
|
|
90
94
|
const accepted = spawnDaemon(fixture, {attestation: firstAttestation, releaseId: "accepted", revision: "accepted123"})
|
|
91
95
|
|
|
92
96
|
try {
|
|
93
97
|
await waitForLog(accepted, "control socket listening")
|
|
98
|
+
await waitForFile(fixture.statePath)
|
|
94
99
|
const badConfig = JSON.parse((await fs.readFile(fixture.configPath, "utf8")).replace(/^module\.exports = /, ""))
|
|
95
100
|
badConfig.processes[0].health.path = "/never-ready"
|
|
96
101
|
badConfig.processes[0].health.timeoutMs = 100
|
|
@@ -106,10 +111,20 @@ test("failed takeover bootstrap preserves the previously accepted owner", async
|
|
|
106
111
|
])
|
|
107
112
|
|
|
108
113
|
assert.notEqual(result.code, 0)
|
|
114
|
+
const records = parseStructuredOutput(result.output)
|
|
115
|
+
const failure = records.find((record) => record.message === "bootstrap activation failed")
|
|
116
|
+
const candidatePid = Number(await fs.readFile(fixture.startedPath, "utf8"))
|
|
117
|
+
|
|
118
|
+
assert.match(String(failure?.data?.error), /Health check failed/)
|
|
119
|
+
assert.match(String(failure?.data?.stack), /Error: Health check failed/)
|
|
120
|
+
assert.equal(isProcessAlive(candidatePid), false, "the failed candidate process must be stopped")
|
|
109
121
|
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
110
122
|
assert.equal(status.activeReleaseId, "accepted")
|
|
111
123
|
assert.ok(status.bootstrap && typeof status.bootstrap === "object" && !Array.isArray(status.bootstrap))
|
|
112
124
|
assert.equal(status.bootstrap.attestation, firstAttestation)
|
|
125
|
+
const priorState = await readState(fixture.statePath)
|
|
126
|
+
|
|
127
|
+
assert.ok(priorState && typeof priorState === "object" && !Array.isArray(priorState) && priorState.activeReleaseId === "accepted", "candidate cleanup must preserve the prior owner's state")
|
|
113
128
|
} finally {
|
|
114
129
|
accepted.kill("SIGTERM")
|
|
115
130
|
if (accepted.exitCode === null) await once(accepted, "exit")
|
|
@@ -267,24 +282,56 @@ test("SIGTERM during multi-process bootstrap owns every process started after sh
|
|
|
267
282
|
}
|
|
268
283
|
})
|
|
269
284
|
|
|
270
|
-
test("failed
|
|
271
|
-
const fixture = await createFixture({
|
|
285
|
+
test("failed ordinary bootstrap completely shuts down attempt-owned resources and exposes the cause", async () => {
|
|
286
|
+
const fixture = await createFixture({attemptOwnedProcesses: true, fixedPorts: true, missingControlParent: true})
|
|
272
287
|
|
|
273
288
|
try {
|
|
274
289
|
const result = await runDaemon([
|
|
275
290
|
"--config", fixture.configPath,
|
|
276
291
|
"--release-path", fixture.root,
|
|
277
|
-
"--release-id", "
|
|
278
|
-
"--revision", "
|
|
292
|
+
"--release-id", "ordinary-failure",
|
|
293
|
+
"--revision", "ordinary123"
|
|
279
294
|
])
|
|
280
|
-
const records = result.output
|
|
295
|
+
const records = parseStructuredOutput(result.output)
|
|
296
|
+
const failure = records.find((record) => record.message === "bootstrap activation failed")
|
|
297
|
+
|
|
298
|
+
assert.notEqual(result.code, 0)
|
|
299
|
+
assert.equal(failure?.data?.releaseId, "ordinary-failure")
|
|
300
|
+
assert.equal(failure?.data?.status, "error")
|
|
301
|
+
assert.match(String(failure?.data?.error), /listen (?:EACCES|ENOENT)/)
|
|
302
|
+
assert.match(String(failure?.data?.stack), /Error: listen (?:EACCES|ENOENT)/)
|
|
303
|
+
await assertAttemptResourcesStopped(fixture, records)
|
|
304
|
+
await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
|
|
305
|
+
} finally {
|
|
306
|
+
await killAttemptProcesses(fixture.lifecyclePath)
|
|
307
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test("failed takeover retirement completely shuts down and exits non-zero instead of lingering", async () => {
|
|
312
|
+
const fixture = await createFixture({attemptOwnedProcesses: true, fixedPorts: true})
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const result = await runDaemon([
|
|
316
|
+
"--config", fixture.configPath,
|
|
317
|
+
"--release-path", fixture.root,
|
|
318
|
+
"--release-id", "orphaned-candidate",
|
|
319
|
+
"--revision", "candidate123",
|
|
320
|
+
"--boot-attestation", secondAttestation,
|
|
321
|
+
"--takeover-owner"
|
|
322
|
+
], {timeoutMs: 2000})
|
|
323
|
+
const records = parseStructuredOutput(result.output)
|
|
281
324
|
const failure = records.find((record) => record.message === "bootstrap activation failed")
|
|
282
325
|
|
|
283
326
|
assert.notEqual(result.code, 0)
|
|
284
|
-
assert.
|
|
285
|
-
assert.
|
|
327
|
+
assert.equal(failure?.data?.releaseId, "orphaned-candidate")
|
|
328
|
+
assert.equal(failure?.data?.status, "error")
|
|
329
|
+
assert.match(String(failure?.data?.error), /connect ENOENT/)
|
|
330
|
+
assert.match(String(failure?.data?.stack), /Error: connect ENOENT/)
|
|
331
|
+
await assertAttemptResourcesStopped(fixture, records)
|
|
286
332
|
await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
|
|
287
333
|
} finally {
|
|
334
|
+
await killAttemptProcesses(fixture.lifecyclePath)
|
|
288
335
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
289
336
|
}
|
|
290
337
|
})
|
|
@@ -350,12 +397,12 @@ test("failed daemon bootstrap preserves prior live process records in statePath"
|
|
|
350
397
|
})
|
|
351
398
|
|
|
352
399
|
/**
|
|
353
|
-
* @param {{healthGate?: boolean, healthPath?: string, healthTimeoutMs?: number, multiProcessSignal?: boolean, persistState?: boolean}} [options] - Fixture options.
|
|
354
|
-
* @returns {Promise<{configPath: string, gatePath: string, healthGatePath: string, lifecyclePath: string, root: string, socketPath: string, startedPath: string, statePath: string, stoppedPath: string}>} Fixture paths.
|
|
400
|
+
* @param {{attemptOwnedProcesses?: boolean, fixedPorts?: boolean, healthGate?: boolean, healthPath?: string, healthTimeoutMs?: number, missingControlParent?: boolean, multiProcessSignal?: boolean, persistState?: boolean}} [options] - Fixture options.
|
|
401
|
+
* @returns {Promise<{configPath: string, gatePath: string, healthGatePath: string, lifecyclePath: string, processPort: number, proxyPort: number, root: string, socketPath: string, startedPath: string, statePath: string, stoppedPath: string}>} Fixture paths.
|
|
355
402
|
*/
|
|
356
|
-
async function createFixture({healthGate = false, healthPath = "/ping", healthTimeoutMs = 1000, multiProcessSignal = false, persistState = false} = {}) {
|
|
403
|
+
async function createFixture({attemptOwnedProcesses = false, fixedPorts = false, healthGate = false, healthPath = "/ping", healthTimeoutMs = 1000, missingControlParent = false, multiProcessSignal = false, persistState = false} = {}) {
|
|
357
404
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-bootstrap-"))
|
|
358
|
-
const socketPath = path.join(root, "control.sock")
|
|
405
|
+
const socketPath = missingControlParent ? path.join(root, "missing", "control.sock") : path.join(root, "control.sock")
|
|
359
406
|
const statePath = path.join(root, "state.json")
|
|
360
407
|
const startedPath = path.join(root, "started.pid")
|
|
361
408
|
const stoppedPath = path.join(root, "stopped.pid")
|
|
@@ -364,8 +411,12 @@ async function createFixture({healthGate = false, healthPath = "/ping", healthTi
|
|
|
364
411
|
const healthGatePath = path.join(root, "health-ready")
|
|
365
412
|
const configPath = path.join(root, "rollbridge.js")
|
|
366
413
|
const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`
|
|
414
|
+
const ownedCommand = `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(ownedChildPath)}`
|
|
415
|
+
const ownedWebCommand = `exec ${command}`
|
|
416
|
+
const [processPort, proxyPort] = fixedPorts ? await availablePorts(2) : [0, 0]
|
|
367
417
|
const lifecycleEnv = {ROLLBRIDGE_TEST_LIFECYCLE_PATH: lifecyclePath}
|
|
368
418
|
const webEnv = {
|
|
419
|
+
...(attemptOwnedProcesses ? lifecycleEnv : {}),
|
|
369
420
|
ROLLBRIDGE_TEST_STARTED_PATH: startedPath,
|
|
370
421
|
ROLLBRIDGE_TEST_STOPPED_PATH: stoppedPath,
|
|
371
422
|
...(healthGate ? {ROLLBRIDGE_TEST_HEALTH_GATE_PATH: healthGatePath} : {})
|
|
@@ -377,8 +428,13 @@ async function createFixture({healthGate = false, healthPath = "/ping", healthTi
|
|
|
377
428
|
{command: `trap '' TERM; printf '%s\\n' '{"event":"shutdown"}' >> ${JSON.stringify(lifecyclePath)}; kill -TERM "$ROLLBRIDGE_TEST_DAEMON_PID"; printf '{"event":"started","pid":%s,"processId":"database","replicaIndex":"0"}\\n' "$$" >> ${JSON.stringify(lifecyclePath)}; read ignored < ${JSON.stringify(gatePath)}`, env: lifecycleEnv, id: "database", policy: "service"},
|
|
378
429
|
{command: `exec ${command}`, env: lifecycleEnv, id: "worker", policy: "companion", replicas: 2},
|
|
379
430
|
{command: `exec ${command}`, env: lifecycleEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}
|
|
431
|
+
] : attemptOwnedProcesses ? [
|
|
432
|
+
{command: ownedCommand, env: lifecycleEnv, id: "database", policy: "service"},
|
|
433
|
+
{command: ownedCommand, env: lifecycleEnv, id: "worker", policy: "companion"},
|
|
434
|
+
{command: ownedCommand, env: lifecycleEnv, id: "scheduler", policy: "singleton"},
|
|
435
|
+
{command: ownedWebCommand, env: webEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: processPort, to: processPort}}
|
|
380
436
|
] : [{command, env: webEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}],
|
|
381
|
-
proxy: {host: "127.0.0.1", port:
|
|
437
|
+
proxy: {host: "127.0.0.1", port: proxyPort},
|
|
382
438
|
...(persistState ? {statePath} : {})
|
|
383
439
|
}
|
|
384
440
|
|
|
@@ -392,7 +448,7 @@ async function createFixture({healthGate = false, healthPath = "/ping", healthTi
|
|
|
392
448
|
}
|
|
393
449
|
|
|
394
450
|
await fs.writeFile(configPath, `${setup}module.exports = ${JSON.stringify(config, null, 2)}\n`)
|
|
395
|
-
return {configPath, gatePath, healthGatePath, lifecyclePath, root, socketPath, startedPath, statePath, stoppedPath}
|
|
451
|
+
return {configPath, gatePath, healthGatePath, lifecyclePath, processPort, proxyPort, root, socketPath, startedPath, statePath, stoppedPath}
|
|
396
452
|
}
|
|
397
453
|
|
|
398
454
|
/**
|
|
@@ -427,6 +483,12 @@ async function waitForFile(filePath) {
|
|
|
427
483
|
const watcher = fs.watch(path.dirname(filePath))
|
|
428
484
|
|
|
429
485
|
try {
|
|
486
|
+
try {
|
|
487
|
+
return await fs.readFile(filePath, "utf8")
|
|
488
|
+
} catch (error) {
|
|
489
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
|
|
490
|
+
}
|
|
491
|
+
|
|
430
492
|
for await (const event of watcher) {
|
|
431
493
|
if (event.filename === path.basename(filePath)) return await fs.readFile(filePath, "utf8")
|
|
432
494
|
}
|
|
@@ -452,26 +514,143 @@ function spawnDaemon(fixture, release) {
|
|
|
452
514
|
|
|
453
515
|
/**
|
|
454
516
|
* @param {string[]} args - Daemon arguments.
|
|
517
|
+
* @param {{timeoutMs?: number}} [options] - Process execution options.
|
|
455
518
|
* @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
|
|
456
519
|
*/
|
|
457
|
-
async function runDaemon(args) {
|
|
458
|
-
return await runRollbridge(["daemon", ...args])
|
|
520
|
+
async function runDaemon(args, options) {
|
|
521
|
+
return await runRollbridge(["daemon", ...args], options)
|
|
459
522
|
}
|
|
460
523
|
|
|
461
524
|
/**
|
|
462
525
|
* @param {string[]} args - Rollbridge command and arguments.
|
|
526
|
+
* @param {{timeoutMs?: number}} [options] - Process execution options.
|
|
463
527
|
* @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
|
|
464
528
|
*/
|
|
465
|
-
async function runRollbridge(args) {
|
|
529
|
+
async function runRollbridge(args, {timeoutMs} = {}) {
|
|
466
530
|
const child = spawn(process.execPath, [binPath, ...args], {stdio: ["ignore", "pipe", "pipe"]})
|
|
467
531
|
let output = ""
|
|
468
532
|
let stderr = ""
|
|
469
533
|
|
|
470
534
|
child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
|
|
471
535
|
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk })
|
|
472
|
-
|
|
536
|
+
let timer
|
|
537
|
+
|
|
538
|
+
const exited = once(child, "exit")
|
|
539
|
+
const result = timeoutMs === undefined ? await exited : await Promise.race([
|
|
540
|
+
exited,
|
|
541
|
+
new Promise((resolve) => {
|
|
542
|
+
timer = setTimeout(() => {
|
|
543
|
+
child.kill("SIGKILL")
|
|
544
|
+
resolve(["timeout"])
|
|
545
|
+
}, timeoutMs)
|
|
546
|
+
})
|
|
547
|
+
])
|
|
548
|
+
|
|
549
|
+
if (timer) clearTimeout(timer)
|
|
550
|
+
if (result[0] === "timeout") {
|
|
551
|
+
await exited
|
|
552
|
+
throw new Error(`Rollbridge did not terminate within ${timeoutMs}ms`)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const [code] = result
|
|
556
|
+
|
|
557
|
+
return {code: typeof code === "number" || code === null ? code : null, output, stderr}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* @param {string} output - JSON-lines daemon output.
|
|
562
|
+
* @returns {StructuredRecord[]} Parsed records.
|
|
563
|
+
*/
|
|
564
|
+
function parseStructuredOutput(output) {
|
|
565
|
+
return output.split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* @param {{lifecyclePath: string, processPort: number, proxyPort: number}} fixture - Attempt fixture.
|
|
570
|
+
* @param {StructuredRecord[]} records - Structured daemon records.
|
|
571
|
+
* @returns {Promise<void>} Resolves after all owned resources are verified stopped.
|
|
572
|
+
*/
|
|
573
|
+
async function assertAttemptResourcesStopped(fixture, records) {
|
|
574
|
+
const events = await readLifecycleEvents(fixture.lifecyclePath)
|
|
575
|
+
const started = events.filter((event) => event.event === "started")
|
|
576
|
+
const stoppedPids = new Set(events.filter((event) => event.event === "stopped").map((event) => event.pid))
|
|
577
|
+
const expectedProcessIds = new Set(["database", "scheduler", "web", "worker"])
|
|
578
|
+
const managedStarts = new Set(records.filter((record) => record.message === "process started").map((record) => record.data?.processId))
|
|
579
|
+
const managedExits = new Set(records.filter((record) => record.message === "process exited").map((record) => record.data?.processId))
|
|
580
|
+
|
|
581
|
+
assert.deepEqual(managedStarts, expectedProcessIds)
|
|
582
|
+
assert.deepEqual(managedExits, expectedProcessIds)
|
|
583
|
+
for (const event of started) {
|
|
584
|
+
assert.equal(stoppedPids.has(event.pid), true, `${event.processId} must receive graceful shutdown`)
|
|
585
|
+
assert.equal(isProcessAlive(Number(event.pid)), false, `${event.processId} pid ${event.pid} must be gone before daemon exit`)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
await assertPortAvailable(fixture.processPort)
|
|
589
|
+
await assertPortAvailable(fixture.proxyPort)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* @param {string} lifecyclePath - Fixture lifecycle log.
|
|
594
|
+
* @returns {Promise<Record<string, import("../src/json.js").JsonValue>[]>} Parsed lifecycle events.
|
|
595
|
+
*/
|
|
596
|
+
async function readLifecycleEvents(lifecyclePath) {
|
|
597
|
+
return (await fs.readFile(lifecyclePath, "utf8")).trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Best-effort cleanup for a deliberately failing lingering-process regression.
|
|
602
|
+
* @param {string} lifecyclePath - Fixture lifecycle log.
|
|
603
|
+
* @returns {Promise<void>} Resolves after known fixture process groups are stopped.
|
|
604
|
+
*/
|
|
605
|
+
async function killAttemptProcesses(lifecyclePath) {
|
|
606
|
+
try {
|
|
607
|
+
const events = await readLifecycleEvents(lifecyclePath)
|
|
608
|
+
|
|
609
|
+
for (const event of events) {
|
|
610
|
+
const pid = Number(event.pid)
|
|
611
|
+
|
|
612
|
+
if (event.event === "started" && isProcessAlive(pid)) process.kill(-pid, "SIGKILL")
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
// The attempt can fail before creating the lifecycle file.
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* @param {number} port - Local port expected to be free.
|
|
621
|
+
* @returns {Promise<void>} Resolves after binding and releasing the port.
|
|
622
|
+
*/
|
|
623
|
+
async function assertPortAvailable(port) {
|
|
624
|
+
const server = net.createServer()
|
|
625
|
+
|
|
626
|
+
await new Promise((resolve, reject) => {
|
|
627
|
+
server.once("error", reject)
|
|
628
|
+
server.listen(port, "127.0.0.1", () => resolve(undefined))
|
|
629
|
+
})
|
|
630
|
+
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Reserves distinct ephemeral ports until all have been observed, then releases them.
|
|
635
|
+
* @param {number} count - Number of ports.
|
|
636
|
+
* @returns {Promise<number[]>} Available port numbers.
|
|
637
|
+
*/
|
|
638
|
+
async function availablePorts(count) {
|
|
639
|
+
const servers = Array.from({length: count}, () => net.createServer())
|
|
640
|
+
|
|
641
|
+
await Promise.all(servers.map((server) => new Promise((resolve, reject) => {
|
|
642
|
+
server.once("error", reject)
|
|
643
|
+
server.listen(0, "127.0.0.1", () => resolve(undefined))
|
|
644
|
+
})))
|
|
645
|
+
const ports = servers.map((server) => {
|
|
646
|
+
const address = server.address()
|
|
647
|
+
|
|
648
|
+
if (!address || typeof address === "string") throw new Error("Expected a TCP server address")
|
|
649
|
+
return address.port
|
|
650
|
+
})
|
|
473
651
|
|
|
474
|
-
|
|
652
|
+
await Promise.all(servers.map((server) => new Promise((resolve) => server.close(() => resolve(undefined)))))
|
|
653
|
+
return ports
|
|
475
654
|
}
|
|
476
655
|
|
|
477
656
|
/**
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs"
|
|
4
|
+
|
|
5
|
+
const lifecyclePath = process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH
|
|
6
|
+
|
|
7
|
+
if (!lifecyclePath) throw new Error("ROLLBRIDGE_TEST_LIFECYCLE_PATH is required")
|
|
8
|
+
|
|
9
|
+
/** @param {string} event - Lifecycle event. */
|
|
10
|
+
const record = (event) => {
|
|
11
|
+
fs.appendFileSync(lifecyclePath, `${JSON.stringify({event, pid: process.pid, processId: process.env.ROLLBRIDGE_PROCESS_ID})}\n`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
record("started")
|
|
15
|
+
process.on("SIGTERM", () => {
|
|
16
|
+
record("stopped")
|
|
17
|
+
process.exit(0)
|
|
18
|
+
})
|
|
19
|
+
setInterval(() => {}, 1000)
|
|
@@ -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",
|
|
@@ -401,19 +403,25 @@ test("sends the configured stopSignal as the graceful stop signal", async () =>
|
|
|
401
403
|
|
|
402
404
|
/** @type {string[]} */
|
|
403
405
|
const signals = []
|
|
404
|
-
const
|
|
406
|
+
const killProcess = managed.killProcess.bind(managed)
|
|
405
407
|
|
|
406
|
-
managed.
|
|
408
|
+
managed.killProcess = (pid, signal) => {
|
|
407
409
|
signals.push(signal)
|
|
408
|
-
|
|
410
|
+
killProcess(pid, signal)
|
|
409
411
|
}
|
|
410
412
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
+
try {
|
|
414
|
+
await managed.start()
|
|
415
|
+
await waitFor(() => fs.existsSync(readyPath))
|
|
416
|
+
await managed.stop()
|
|
413
417
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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 () => {
|
|
@@ -431,11 +439,11 @@ test("indefinite stop waits for the process to exit without SIGKILL", async () =
|
|
|
431
439
|
})
|
|
432
440
|
/** @type {string[]} */
|
|
433
441
|
const signals = []
|
|
434
|
-
const
|
|
442
|
+
const killProcess = managed.killProcess.bind(managed)
|
|
435
443
|
|
|
436
|
-
managed.
|
|
444
|
+
managed.killProcess = (pid, signal) => {
|
|
437
445
|
signals.push(signal)
|
|
438
|
-
|
|
446
|
+
killProcess(pid, signal)
|
|
439
447
|
}
|
|
440
448
|
|
|
441
449
|
try {
|
|
@@ -443,7 +451,7 @@ test("indefinite stop waits for the process to exit without SIGKILL", async () =
|
|
|
443
451
|
await managed.stop()
|
|
444
452
|
|
|
445
453
|
assert.equal(managed.status().state, "stopped")
|
|
446
|
-
assert.deepEqual(signals, ["SIGTERM"])
|
|
454
|
+
assert.deepEqual(signals, ["SIGTERM", "SIGTERM"])
|
|
447
455
|
} finally {
|
|
448
456
|
await managed.stop()
|
|
449
457
|
}
|
|
@@ -491,6 +499,75 @@ test("stop waits for process group descendants after the detached shell exits",
|
|
|
491
499
|
}
|
|
492
500
|
})
|
|
493
501
|
|
|
502
|
+
test("stop does not return while a gracefully stopped descendant remains unreaped", async () => {
|
|
503
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rollbridge-process-group-"))
|
|
504
|
+
const pidPath = path.join(dir, "child.pid")
|
|
505
|
+
const child = [
|
|
506
|
+
"const fs = require('node:fs')",
|
|
507
|
+
`fs.writeFileSync(${JSON.stringify(pidPath)}, String(process.pid))`,
|
|
508
|
+
"process.on('SIGTERM', () => setTimeout(() => process.exit(0), 50))",
|
|
509
|
+
"setInterval(() => {}, 1000)"
|
|
510
|
+
].join("; ")
|
|
511
|
+
const managed = new ManagedProcess({
|
|
512
|
+
command: `trap 'exit 0' TERM; ${JSON.stringify(process.execPath)} -e ${JSON.stringify(child)} & wait`,
|
|
513
|
+
cwd: undefined,
|
|
514
|
+
env: {},
|
|
515
|
+
id: "worker",
|
|
516
|
+
logger: () => {},
|
|
517
|
+
outputLines: 50,
|
|
518
|
+
restartDelayMs: 10,
|
|
519
|
+
shouldRestart: () => false,
|
|
520
|
+
stopSignal: "SIGTERM",
|
|
521
|
+
stopTimeoutMs: 2000
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
await managed.start()
|
|
526
|
+
await waitFor(() => fs.existsSync(pidPath))
|
|
527
|
+
const childPid = Number(fs.readFileSync(pidPath, "utf8"))
|
|
528
|
+
|
|
529
|
+
await managed.stop()
|
|
530
|
+
|
|
531
|
+
assert.throws(() => process.kill(childPid, 0), {code: "ESRCH"})
|
|
532
|
+
} finally {
|
|
533
|
+
await managed.stop()
|
|
534
|
+
fs.rmSync(dir, {force: true, recursive: true})
|
|
535
|
+
}
|
|
536
|
+
})
|
|
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
|
+
|
|
494
571
|
test("a memory restart respawns and is counted when the supervisor still wants the process", async () => {
|
|
495
572
|
const managed = buildLongLived(() => true)
|
|
496
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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")
|