rollbridge 0.1.22 → 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
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([
|
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
|
|
|
@@ -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")
|