rollbridge 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -837,6 +837,7 @@ export default class RollbridgeDaemon {
|
|
|
837
837
|
}
|
|
838
838
|
this.persistenceEnabled = false
|
|
839
839
|
if (this.pendingWrite) await this.pendingWrite
|
|
840
|
+
this.stateCleanupEnabled = false
|
|
840
841
|
this.controlClosePromise = this.closeServer(this.controlServer)
|
|
841
842
|
for (const socket of this.controlSockets) if (socket !== completionSocket) socket.destroy()
|
|
842
843
|
await Promise.all([
|
|
@@ -881,12 +882,13 @@ export default class RollbridgeDaemon {
|
|
|
881
882
|
}
|
|
882
883
|
|
|
883
884
|
await captureShutdownError(cleanupErrors, "proxy close", async () => this.proxy.close())
|
|
884
|
-
const
|
|
885
|
-
...[...this.services.values()].map((processInstance) => processInstance.stop()),
|
|
885
|
+
const dependentStopResults = await Promise.allSettled([
|
|
886
886
|
...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
|
|
887
887
|
...[...this.startingReleases].map((release) => release.stop()),
|
|
888
888
|
...[...this.releases.values()].map((release) => release.stop())
|
|
889
889
|
])
|
|
890
|
+
const serviceStopResults = await Promise.allSettled([...this.services.values()].map((processInstance) => processInstance.stop()))
|
|
891
|
+
const stopResults = [...dependentStopResults, ...serviceStopResults]
|
|
890
892
|
await captureShutdownError(cleanupErrors, "proxy server close", () => this.closeServer(this.proxyServer))
|
|
891
893
|
|
|
892
894
|
// Wait for any in-flight write first so it can't recreate or overwrite the final state (no
|
package/src/managed-process.js
CHANGED
|
@@ -364,18 +364,24 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
364
364
|
|
|
365
365
|
// 3. Stop whatever is still running, then SIGKILL if it outlasts the graceful window.
|
|
366
366
|
if (this.processGroupExists(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 {
|
|
369
374
|
this.intentionalStopSignal = /** @type {ProcessExitSignal} */ (this.stopSignal)
|
|
370
|
-
|
|
375
|
+
gracefulDeadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
|
|
376
|
+
await this.signalProcessGroup(this.stopSignal, pgid, gracefulDeadline)
|
|
371
377
|
}
|
|
372
378
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (!(await this.waitForProcessGroupExit(pgid, timeoutMs))) {
|
|
379
|
+
if (!(await this.waitForProcessGroupExit(pgid, gracefulDeadline))) {
|
|
376
380
|
this.logger("process stop timed out; sending SIGKILL", {id: this.id, pid: pgid})
|
|
377
|
-
|
|
378
|
-
|
|
381
|
+
const killDeadline = Date.now() + 5000
|
|
382
|
+
|
|
383
|
+
await this.signalProcessGroup("SIGKILL", pgid, killDeadline)
|
|
384
|
+
await this.waitForProcessGroupExit(pgid, killDeadline)
|
|
379
385
|
}
|
|
380
386
|
}
|
|
381
387
|
|
|
@@ -499,10 +505,10 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
499
505
|
* Falls back to the portable group signal when procfs cannot identify group members.
|
|
500
506
|
* @param {string} signal - Signal name.
|
|
501
507
|
* @param {number} pgid - Process group id.
|
|
502
|
-
* @param {
|
|
508
|
+
* @param {number | undefined} deadline - Absolute graceful deadline, or undefined to wait indefinitely.
|
|
503
509
|
* @returns {Promise<void>} Resolves after the leader has been signalled.
|
|
504
510
|
*/
|
|
505
|
-
async signalProcessGroup(signal, pgid,
|
|
511
|
+
async signalProcessGroup(signal, pgid, deadline) {
|
|
506
512
|
const members = processGroupMembers(pgid)
|
|
507
513
|
const descendants = members.filter((member) => member.pid !== pgid)
|
|
508
514
|
|
|
@@ -512,11 +518,11 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
512
518
|
}
|
|
513
519
|
|
|
514
520
|
for (const descendant of descendants) this.killProcess(descendant.pid, signal)
|
|
515
|
-
const deadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
|
|
516
|
-
|
|
517
521
|
while (processGroupMembers(pgid).some((member) => member.pid !== pgid)) {
|
|
518
522
|
if (deadline !== undefined && Date.now() >= deadline) break
|
|
519
|
-
|
|
523
|
+
const waitMs = deadline === undefined ? 25 : Math.min(25, deadline - Date.now())
|
|
524
|
+
|
|
525
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
|
520
526
|
}
|
|
521
527
|
|
|
522
528
|
this.killProcess(pgid, signal)
|
|
@@ -558,15 +564,15 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
558
564
|
|
|
559
565
|
/**
|
|
560
566
|
* @param {number} pgid - Process group id.
|
|
561
|
-
* @param {
|
|
567
|
+
* @param {number | undefined} deadline - Absolute deadline, or undefined to wait indefinitely.
|
|
562
568
|
* @returns {Promise<boolean>} True once the process group no longer exists.
|
|
563
569
|
*/
|
|
564
|
-
async waitForProcessGroupExit(pgid,
|
|
565
|
-
const deadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
|
|
566
|
-
|
|
570
|
+
async waitForProcessGroupExit(pgid, deadline) {
|
|
567
571
|
while (this.processGroupExists(pgid)) {
|
|
568
572
|
if (deadline !== undefined && Date.now() >= deadline) return false
|
|
569
|
-
|
|
573
|
+
const waitMs = deadline === undefined ? 10 : Math.min(10, deadline - Date.now())
|
|
574
|
+
|
|
575
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
|
570
576
|
}
|
|
571
577
|
|
|
572
578
|
return true
|
|
@@ -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",
|
|
@@ -408,12 +410,18 @@ test("sends the configured stopSignal as the graceful stop signal", async () =>
|
|
|
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 () => {
|
|
@@ -527,6 +535,39 @@ test("stop does not return while a gracefully stopped descendant remains unreape
|
|
|
527
535
|
}
|
|
528
536
|
})
|
|
529
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
|
+
|
|
530
571
|
test("a memory restart respawns and is counted when the supervisor still wants the process", async () => {
|
|
531
572
|
const managed = buildLongLived(() => true)
|
|
532
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
|
|
|
@@ -115,6 +115,60 @@ test("shutdown response waits for endpoint and owned-process cleanup before imme
|
|
|
115
115
|
}
|
|
116
116
|
})
|
|
117
117
|
|
|
118
|
+
test("shutdown keeps daemon services alive until release-owned dependents stop", async () => {
|
|
119
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-service-order-"))
|
|
120
|
+
const socketPath = path.join(root, "control.sock")
|
|
121
|
+
const processCommand = `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`
|
|
122
|
+
const config = buildConfig(socketPath, {
|
|
123
|
+
companion: {command: processCommand, gracefulStopMs: "indefinite", id: "worker", policy: "companion"},
|
|
124
|
+
service: {command: processCommand, id: "coordinator", policy: "service"}
|
|
125
|
+
})
|
|
126
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
127
|
+
/** @type {() => void} */
|
|
128
|
+
let releaseWorker = () => {}
|
|
129
|
+
const workerGate = new Promise((resolve) => { releaseWorker = () => resolve(undefined) })
|
|
130
|
+
let serviceStopStarted = false
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await daemon.start()
|
|
134
|
+
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
135
|
+
|
|
136
|
+
const release = daemon.activeRelease
|
|
137
|
+
const coordinator = daemon.services.get("coordinator")
|
|
138
|
+
|
|
139
|
+
assert.ok(release)
|
|
140
|
+
assert.ok(coordinator)
|
|
141
|
+
|
|
142
|
+
const originalReleaseStop = release.stop.bind(release)
|
|
143
|
+
const originalCoordinatorStop = coordinator.stop.bind(coordinator)
|
|
144
|
+
let signalReleaseStopStarted = () => {}
|
|
145
|
+
const releaseStopStarted = new Promise((resolve) => { signalReleaseStopStarted = () => resolve(undefined) })
|
|
146
|
+
|
|
147
|
+
release.stop = async () => {
|
|
148
|
+
signalReleaseStopStarted()
|
|
149
|
+
await workerGate
|
|
150
|
+
await originalReleaseStop()
|
|
151
|
+
}
|
|
152
|
+
coordinator.stop = async () => {
|
|
153
|
+
serviceStopStarted = true
|
|
154
|
+
await originalCoordinatorStop()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const shutdown = daemon.shutdown()
|
|
158
|
+
|
|
159
|
+
await releaseStopStarted
|
|
160
|
+
const serviceStoppedWhileWorkerWasDraining = serviceStopStarted
|
|
161
|
+
releaseWorker()
|
|
162
|
+
await shutdown
|
|
163
|
+
|
|
164
|
+
assert.equal(serviceStoppedWhileWorkerWasDraining, false, "a worker must retain access to daemon services throughout its drain")
|
|
165
|
+
} finally {
|
|
166
|
+
releaseWorker()
|
|
167
|
+
await daemon.shutdown()
|
|
168
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
118
172
|
test("external-owner retirement releases listeners before a long-draining companion exits", async () => {
|
|
119
173
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-"))
|
|
120
174
|
const socketPath = path.join(root, "rollbridge.sock")
|
|
@@ -152,6 +206,41 @@ test("external-owner retirement releases listeners before a long-draining compan
|
|
|
152
206
|
}
|
|
153
207
|
})
|
|
154
208
|
|
|
209
|
+
test("a retired owner cannot clear replacement state during late shutdown", async () => {
|
|
210
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-retired-owner-state-"))
|
|
211
|
+
const socketPath = path.join(root, "rollbridge.sock")
|
|
212
|
+
const statePath = path.join(root, "state.json")
|
|
213
|
+
const config = normalizeConfig({...rawConfig(socketPath), statePath})
|
|
214
|
+
const retired = new RollbridgeDaemon({config, logger: () => {}})
|
|
215
|
+
let replacement
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
await retired.start()
|
|
219
|
+
await retired.deploy({releaseId: "retired", releasePath: root, revision: "retired"})
|
|
220
|
+
if (retired.pendingWrite) await retired.pendingWrite
|
|
221
|
+
await retired.retireOwner({attestation: `sha256:${"a".repeat(64)}`})
|
|
222
|
+
|
|
223
|
+
replacement = new RollbridgeDaemon({config, logger: () => {}})
|
|
224
|
+
await replacement.start({reportOrphans: false})
|
|
225
|
+
await replacement.deploy({releaseId: "replacement", releasePath: root, revision: "replacement"})
|
|
226
|
+
if (replacement.pendingWrite) await replacement.pendingWrite
|
|
227
|
+
|
|
228
|
+
const replacementState = /** @type {{activeReleaseId: string} | undefined} */ (await readState(statePath))
|
|
229
|
+
|
|
230
|
+
assert.equal(replacementState?.activeReleaseId, "replacement")
|
|
231
|
+
|
|
232
|
+
await retired.shutdown()
|
|
233
|
+
|
|
234
|
+
const stateAfterRetiredShutdown = /** @type {{activeReleaseId: string} | undefined} */ (await readState(statePath))
|
|
235
|
+
|
|
236
|
+
assert.equal(stateAfterRetiredShutdown?.activeReleaseId, "replacement", "late shutdown of the retired owner must preserve replacement state")
|
|
237
|
+
} finally {
|
|
238
|
+
if (replacement) await replacement.shutdown()
|
|
239
|
+
await retired.shutdown()
|
|
240
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
|
|
155
244
|
test("control socket unlink failure is reported only after owned cleanup completes", async () => {
|
|
156
245
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-unlink-failure-"))
|
|
157
246
|
const socketPath = path.join(root, "control.sock")
|
|
@@ -279,13 +368,13 @@ test("shutdown of an already-stopped endpoint fails explicitly", async () => {
|
|
|
279
368
|
|
|
280
369
|
/**
|
|
281
370
|
* @param {string} socketPath - Control socket path.
|
|
282
|
-
* @param {{companion?: Record<string, import("../src/json.js").JsonValue>}} [options] - Optional
|
|
371
|
+
* @param {{companion?: Record<string, import("../src/json.js").JsonValue>, service?: Record<string, import("../src/json.js").JsonValue>}} [options] - Optional dependent processes.
|
|
283
372
|
* @returns {import("../src/config.js").RollbridgeConfig} Normalized config.
|
|
284
373
|
*/
|
|
285
|
-
function buildConfig(socketPath, {companion} = {}) {
|
|
374
|
+
function buildConfig(socketPath, {companion, service} = {}) {
|
|
286
375
|
return normalizeConfig({
|
|
287
376
|
...rawConfig(socketPath),
|
|
288
|
-
...(companion ? {processes: [companion, ...rawConfig(socketPath).processes]} : {})
|
|
377
|
+
...((companion || service) ? {processes: [...(service ? [service] : []), ...(companion ? [companion] : []), ...rawConfig(socketPath).processes]} : {})
|
|
289
378
|
})
|
|
290
379
|
}
|
|
291
380
|
|