rollbridge 0.1.18 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/docs/cli.md +9 -1
- package/package.json +1 -1
- package/src/daemon.js +114 -22
- package/test/shutdown-completion.test.js +290 -0
package/README.md
CHANGED
|
@@ -509,6 +509,12 @@ Shut down the daemon and managed processes:
|
|
|
509
509
|
rollbridge shutdown --config rollbridge.js
|
|
510
510
|
```
|
|
511
511
|
|
|
512
|
+
A successful shutdown response is emitted only after the targeted control
|
|
513
|
+
endpoint has stopped accepting connections and been removed, owned processes
|
|
514
|
+
and the proxy have stopped, and persistent state cleanup has finished. It is
|
|
515
|
+
therefore safe to start or ensure a replacement daemon immediately, without a
|
|
516
|
+
delay or retry loop. Cleanup failure or an already-missing daemon exits non-zero.
|
|
517
|
+
|
|
512
518
|
Prepare a first Rollbridge deploy by recovering Rollbridge-managed orphans and
|
|
513
519
|
stopping configured legacy processes:
|
|
514
520
|
|
package/docs/cli.md
CHANGED
|
@@ -292,7 +292,15 @@ rollbridge shutdown [--config <path>]
|
|
|
292
292
|
|
|
293
293
|
Stops all managed processes (services, singletons, and releases), closes the
|
|
294
294
|
proxy and control socket, removes the socket file, and prints
|
|
295
|
-
`{"status": "success", "message": "shutdown"}`.
|
|
295
|
+
`{"status": "success", "message": "shutdown"}`. The success response is a
|
|
296
|
+
completion signal, not an early acknowledgement: before sending it, Rollbridge
|
|
297
|
+
stops accepting new control connections, removes the targeted socket, finishes
|
|
298
|
+
owned-process and proxy cleanup, and finalizes persistent state. A caller may
|
|
299
|
+
immediately start or ensure a replacement daemon after the command returns.
|
|
300
|
+
|
|
301
|
+
If cleanup fails, the command exits non-zero with the daemon's error instead of
|
|
302
|
+
reporting success. Calling `shutdown` when no daemon owns the configured control
|
|
303
|
+
socket also remains an explicit connection error.
|
|
296
304
|
|
|
297
305
|
## `validate`
|
|
298
306
|
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -56,11 +56,14 @@ 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.controlSockets = /** @type {Set<net.Socket>} */ (new Set())
|
|
59
60
|
this.proxyPort = /** @type {number | undefined} */ (undefined)
|
|
60
61
|
this.stopping = false
|
|
61
62
|
this.statePath = config.statePath
|
|
62
63
|
this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
|
|
63
64
|
this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
|
|
65
|
+
this.shutdownPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
66
|
+
this.controlClosePromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
64
67
|
this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
|
|
65
68
|
// Still-alive managed processes left by a previous daemon (from statePath), captured at
|
|
66
69
|
// startup and surfaced in status(). The daemon cannot re-manage them, only report them.
|
|
@@ -247,9 +250,17 @@ export default class RollbridgeDaemon {
|
|
|
247
250
|
* @returns {void}
|
|
248
251
|
*/
|
|
249
252
|
handleControlSocket(socket) {
|
|
253
|
+
this.controlSockets.add(socket)
|
|
250
254
|
socket.setEncoding("utf8")
|
|
251
255
|
let buffer = ""
|
|
252
256
|
|
|
257
|
+
socket.once("close", () => this.controlSockets.delete(socket))
|
|
258
|
+
socket.on("error", (error) => {
|
|
259
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : null
|
|
260
|
+
|
|
261
|
+
this.logger("control connection error", {code, error: error.message})
|
|
262
|
+
})
|
|
263
|
+
|
|
253
264
|
socket.on("data", (chunk) => {
|
|
254
265
|
buffer += chunk
|
|
255
266
|
let newlineIndex = buffer.indexOf("\n")
|
|
@@ -269,22 +280,34 @@ export default class RollbridgeDaemon {
|
|
|
269
280
|
* @returns {void}
|
|
270
281
|
*/
|
|
271
282
|
handleControlLine(line, socket) {
|
|
272
|
-
|
|
273
|
-
|
|
283
|
+
const closesConnection = isShutdownControlLine(line)
|
|
284
|
+
const respond = (/** @type {Record<string, JsonValue>} */ response) => {
|
|
285
|
+
const payload = `${JSON.stringify(response)}\n`
|
|
286
|
+
|
|
287
|
+
if (closesConnection) {
|
|
288
|
+
socket.end(payload, () => socket.destroy())
|
|
289
|
+
} else if (!socket.destroyed) {
|
|
290
|
+
socket.write(payload)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
this.executeControlLine(line, socket)
|
|
295
|
+
.then((response) => respond({status: "success", ...response}))
|
|
274
296
|
.catch((error) => {
|
|
275
297
|
this.logger("command failed", {error: error instanceof Error ? error.message : String(error)})
|
|
276
|
-
|
|
298
|
+
respond({
|
|
277
299
|
error: error instanceof Error ? error.message : String(error),
|
|
278
300
|
status: "error"
|
|
279
|
-
})
|
|
301
|
+
})
|
|
280
302
|
})
|
|
281
303
|
}
|
|
282
304
|
|
|
283
305
|
/**
|
|
284
306
|
* @param {string} line - JSON command line.
|
|
307
|
+
* @param {net.Socket} [controlSocket] - Requesting control connection, used only for shutdown completion.
|
|
285
308
|
* @returns {Promise<Record<string, JsonValue>>} Command response.
|
|
286
309
|
*/
|
|
287
|
-
async executeControlLine(line) {
|
|
310
|
+
async executeControlLine(line, controlSocket) {
|
|
288
311
|
const command = JSON.parse(line)
|
|
289
312
|
|
|
290
313
|
if (!command || typeof command !== "object") {
|
|
@@ -327,11 +350,10 @@ export default class RollbridgeDaemon {
|
|
|
327
350
|
}
|
|
328
351
|
|
|
329
352
|
if (commandName === "shutdown") {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
})
|
|
353
|
+
// Stop accepting new control connections before cleanup, but keep this requesting
|
|
354
|
+
// connection open as the completion channel. Waiting for all control connections here
|
|
355
|
+
// would deadlock: server.close() includes the socket awaiting this response.
|
|
356
|
+
await this.shutdown({completionSocket: controlSocket, waitForControlConnections: false})
|
|
335
357
|
|
|
336
358
|
return {message: "shutdown"}
|
|
337
359
|
}
|
|
@@ -763,30 +785,56 @@ export default class RollbridgeDaemon {
|
|
|
763
785
|
}
|
|
764
786
|
}
|
|
765
787
|
|
|
766
|
-
/**
|
|
767
|
-
|
|
768
|
-
|
|
788
|
+
/**
|
|
789
|
+
* Stops proxy, control socket, and child processes.
|
|
790
|
+
* @param {{completionSocket?: net.Socket, waitForControlConnections?: boolean}} [options] - Shutdown connection behavior.
|
|
791
|
+
* @returns {Promise<void>} Resolves when owned resources are stopped (and, by default, control connections close).
|
|
792
|
+
*/
|
|
793
|
+
async shutdown({completionSocket, waitForControlConnections = true} = {}) {
|
|
794
|
+
if (!this.shutdownPromise) this.shutdownPromise = this.performShutdown(completionSocket)
|
|
795
|
+
|
|
796
|
+
await this.shutdownPromise
|
|
797
|
+
if (waitForControlConnections && this.controlClosePromise) await this.controlClosePromise
|
|
798
|
+
}
|
|
769
799
|
|
|
800
|
+
/**
|
|
801
|
+
* @param {net.Socket | undefined} completionSocket - Requester retained for the final response.
|
|
802
|
+
* @returns {Promise<void>} Retires listeners and cleans up every daemon-owned resource.
|
|
803
|
+
*/
|
|
804
|
+
async performShutdown(completionSocket) {
|
|
770
805
|
this.stopping = true
|
|
806
|
+
const cleanupErrors = /** @type {Error[]} */ ([])
|
|
807
|
+
|
|
808
|
+
// server.close() stops new connections synchronously. Unlink immediately afterward so a
|
|
809
|
+
// replacement can bind as soon as cleanup completes; existing connections remain usable for
|
|
810
|
+
// the shutdown completion/error response.
|
|
811
|
+
this.controlClosePromise = this.closeServer(this.controlServer)
|
|
812
|
+
|
|
813
|
+
for (const socket of this.controlSockets) {
|
|
814
|
+
if (socket !== completionSocket) socket.destroy()
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
await captureShutdownError(cleanupErrors, "control socket unlink", () => this.removeControlSocket())
|
|
771
818
|
|
|
772
819
|
if (this.persistTimer) {
|
|
773
820
|
clearInterval(this.persistTimer)
|
|
774
821
|
this.persistTimer = undefined
|
|
775
822
|
}
|
|
776
823
|
|
|
777
|
-
this.proxy.close()
|
|
778
|
-
await Promise.allSettled([
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
await
|
|
824
|
+
await captureShutdownError(cleanupErrors, "proxy close", async () => this.proxy.close())
|
|
825
|
+
const stopResults = await Promise.allSettled([
|
|
826
|
+
...[...this.services.values()].map((processInstance) => processInstance.stop()),
|
|
827
|
+
...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
|
|
828
|
+
...[...this.startingReleases].map((release) => release.stop()),
|
|
829
|
+
...[...this.releases.values()].map((release) => release.stop())
|
|
830
|
+
])
|
|
831
|
+
await captureShutdownError(cleanupErrors, "proxy server close", () => this.closeServer(this.proxyServer))
|
|
785
832
|
|
|
786
833
|
// Wait for any in-flight write first so it can't recreate or overwrite the final state (no
|
|
787
834
|
// new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
|
|
788
835
|
// orphans are not owned by this daemon, so retain their records until they are confirmed gone.
|
|
789
|
-
|
|
836
|
+
await captureShutdownError(cleanupErrors, "persistent state cleanup", async () => {
|
|
837
|
+
if (!this.statePath) return
|
|
790
838
|
if (this.pendingWrite) await this.pendingWrite
|
|
791
839
|
const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
|
|
792
840
|
|
|
@@ -795,7 +843,20 @@ export default class RollbridgeDaemon {
|
|
|
795
843
|
} else {
|
|
796
844
|
await clearState(this.statePath)
|
|
797
845
|
}
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
const stopErrors = stopResults.filter((result) => result.status === "rejected").map((result) => result.reason)
|
|
849
|
+
|
|
850
|
+
if (stopErrors.length > 0) {
|
|
851
|
+
cleanupErrors.push(new AggregateError(stopErrors, `Shutdown failed to stop ${stopErrors.length} owned resource${stopErrors.length === 1 ? "" : "s"}.`))
|
|
798
852
|
}
|
|
853
|
+
|
|
854
|
+
if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, cleanupErrors.map((error) => error.message).join("; "))
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** @returns {Promise<void>} Removes the configured control socket path. */
|
|
858
|
+
async removeControlSocket() {
|
|
859
|
+
await fs.rm(this.config.control.path, {force: true})
|
|
799
860
|
}
|
|
800
861
|
|
|
801
862
|
/**
|
|
@@ -856,6 +917,37 @@ function stringOrUndefined(value) {
|
|
|
856
917
|
return value
|
|
857
918
|
}
|
|
858
919
|
|
|
920
|
+
/**
|
|
921
|
+
* @param {string} line - Raw control line.
|
|
922
|
+
* @returns {boolean} Whether the line requests shutdown and needs a terminal response connection.
|
|
923
|
+
*/
|
|
924
|
+
function isShutdownControlLine(line) {
|
|
925
|
+
try {
|
|
926
|
+
const command = JSON.parse(line)
|
|
927
|
+
|
|
928
|
+
return Boolean(command && typeof command === "object" && command.command === "shutdown")
|
|
929
|
+
} catch {
|
|
930
|
+
return false
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Runs one shutdown cleanup step and records a labeled failure without skipping later cleanup.
|
|
936
|
+
* @param {Error[]} errors - Accumulated cleanup errors.
|
|
937
|
+
* @param {string} label - Non-secret cleanup step name.
|
|
938
|
+
* @param {() => Promise<void>} operation - Cleanup operation.
|
|
939
|
+
* @returns {Promise<void>} Resolves after the operation succeeds or its failure is recorded.
|
|
940
|
+
*/
|
|
941
|
+
async function captureShutdownError(errors, label, operation) {
|
|
942
|
+
try {
|
|
943
|
+
await operation()
|
|
944
|
+
} catch (error) {
|
|
945
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
946
|
+
|
|
947
|
+
errors.push(new Error(`${label} failed: ${reason}`, {cause: error}))
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
859
951
|
const SECRET_BEARING_STATE_KEYS = new Set(["children", "command", "cwd", "env", "environment", "logs", "output"])
|
|
860
952
|
|
|
861
953
|
/**
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import {spawn} from "node:child_process"
|
|
5
|
+
import {once} from "node:events"
|
|
6
|
+
import fs from "node:fs/promises"
|
|
7
|
+
import net from "node:net"
|
|
8
|
+
import os from "node:os"
|
|
9
|
+
import path from "node:path"
|
|
10
|
+
import test from "node:test"
|
|
11
|
+
import {fileURLToPath} from "node:url"
|
|
12
|
+
import {normalizeConfig} from "../src/config.js"
|
|
13
|
+
import {sendControlCommand} from "../src/control-client.js"
|
|
14
|
+
import RollbridgeDaemon from "../src/daemon.js"
|
|
15
|
+
import {isProcessAlive} from "../src/state-store.js"
|
|
16
|
+
|
|
17
|
+
const dummyAppPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "dummy-app.js")
|
|
18
|
+
|
|
19
|
+
test("shutdown response waits for endpoint and owned-process cleanup before immediate replacement", async () => {
|
|
20
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-completion-"))
|
|
21
|
+
const socketPath = path.join(root, "control.sock")
|
|
22
|
+
const unrelatedSocketPath = path.join(root, "unrelated.sock")
|
|
23
|
+
const gatePath = path.join(root, "shutdown.fifo")
|
|
24
|
+
const stoppingPath = path.join(root, "stopping")
|
|
25
|
+
const gate = spawn("mkfifo", [gatePath])
|
|
26
|
+
|
|
27
|
+
assert.equal((await once(gate, "exit"))[0], 0)
|
|
28
|
+
|
|
29
|
+
const config = buildConfig(socketPath, {
|
|
30
|
+
companion: {
|
|
31
|
+
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
|
|
32
|
+
id: "worker",
|
|
33
|
+
lifecycle: {drainTimeoutMs: 0, quietCommand: `printf stopping > ${JSON.stringify(stoppingPath)}; read released < ${JSON.stringify(gatePath)}`},
|
|
34
|
+
policy: "companion"
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
const unrelatedConfig = buildConfig(unrelatedSocketPath)
|
|
38
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
39
|
+
const unrelated = new RollbridgeDaemon({config: unrelatedConfig, logger: () => {}})
|
|
40
|
+
let idleTarget = /** @type {net.Socket | undefined} */ (undefined)
|
|
41
|
+
let idleUnrelated = /** @type {net.Socket | undefined} */ (undefined)
|
|
42
|
+
let replacement
|
|
43
|
+
let gateReleased = false
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await daemon.start()
|
|
47
|
+
await unrelated.start()
|
|
48
|
+
idleTarget = net.createConnection(socketPath)
|
|
49
|
+
idleUnrelated = net.createConnection(unrelatedSocketPath)
|
|
50
|
+
await Promise.all([once(idleTarget, "connect"), once(idleUnrelated, "connect")])
|
|
51
|
+
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
52
|
+
|
|
53
|
+
const workerPid = daemon.activeRelease?.getProcess("worker")?.pid
|
|
54
|
+
|
|
55
|
+
assert.equal(typeof workerPid, "number")
|
|
56
|
+
|
|
57
|
+
const stopping = waitForFile(stoppingPath)
|
|
58
|
+
let shutdownResolved = false
|
|
59
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
60
|
+
.then((response) => {
|
|
61
|
+
shutdownResolved = true
|
|
62
|
+
return response
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await stopping
|
|
66
|
+
|
|
67
|
+
let oldEndpointAccepted = true
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
71
|
+
} catch {
|
|
72
|
+
oldEndpointAccepted = false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const resolvedDuringStop = shutdownResolved
|
|
76
|
+
const processAliveDuringStop = isProcessAlive(/** @type {number} */ (workerPid))
|
|
77
|
+
const idleTargetClosedDuringStop = idleTarget.destroyed
|
|
78
|
+
const idleUnrelatedClosedDuringStop = idleUnrelated.destroyed
|
|
79
|
+
|
|
80
|
+
// Ensure the RED path cannot leave an idle client handle blocking test cleanup.
|
|
81
|
+
idleTarget.destroy()
|
|
82
|
+
|
|
83
|
+
await fs.writeFile(gatePath, "continue\n")
|
|
84
|
+
gateReleased = true
|
|
85
|
+
|
|
86
|
+
const response = await shutdown
|
|
87
|
+
|
|
88
|
+
assert.equal(shutdownResolved, true)
|
|
89
|
+
assert.equal(resolvedDuringStop, false, "shutdown must not acknowledge while an owned process is still stopping")
|
|
90
|
+
assert.equal(oldEndpointAccepted, false, "the targeted endpoint must stop accepting new commands before cleanup")
|
|
91
|
+
assert.equal(processAliveDuringStop, true, "the fixture must hold shutdown while its owned process is alive")
|
|
92
|
+
assert.equal(idleTargetClosedDuringStop, true, "an idle accepted client must be closed when the targeted endpoint retires")
|
|
93
|
+
assert.equal(idleUnrelatedClosedDuringStop, false, "an unrelated daemon's accepted clients must remain untouched")
|
|
94
|
+
assert.deepEqual(response, {message: "shutdown", status: "success"})
|
|
95
|
+
await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
|
|
96
|
+
assert.equal(isProcessAlive(/** @type {number} */ (workerPid)), false)
|
|
97
|
+
|
|
98
|
+
// A different daemon remains reachable; shutdown is scoped to the targeted control endpoint.
|
|
99
|
+
assert.equal((await sendControlCommand({command: {command: "status"}, path: unrelatedSocketPath})).application, "shutdown-unrelated")
|
|
100
|
+
|
|
101
|
+
// Replacement starts immediately, with no polling or retry between truthful ACK and bind.
|
|
102
|
+
replacement = new RollbridgeDaemon({config, logger: () => {}})
|
|
103
|
+
await replacement.start()
|
|
104
|
+
assert.equal((await sendControlCommand({command: {command: "status"}, path: socketPath})).application, "shutdown-target")
|
|
105
|
+
} finally {
|
|
106
|
+
if (!gateReleased) {
|
|
107
|
+
await fs.writeFile(gatePath, "continue\n").catch(() => {})
|
|
108
|
+
}
|
|
109
|
+
idleTarget?.destroy()
|
|
110
|
+
idleUnrelated?.destroy()
|
|
111
|
+
if (replacement) await replacement.shutdown()
|
|
112
|
+
await daemon.shutdown()
|
|
113
|
+
await unrelated.shutdown()
|
|
114
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("control socket unlink failure is reported only after owned cleanup completes", async () => {
|
|
119
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-unlink-failure-"))
|
|
120
|
+
const socketPath = path.join(root, "control.sock")
|
|
121
|
+
const statePath = path.join(root, "state.json")
|
|
122
|
+
const config = normalizeConfig({...rawConfig(socketPath), statePath})
|
|
123
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await daemon.start()
|
|
127
|
+
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
128
|
+
if (daemon.pendingWrite) await daemon.pendingWrite
|
|
129
|
+
|
|
130
|
+
const webPid = daemon.activeRelease?.getProcess("web")?.pid
|
|
131
|
+
const proxyPort = daemon.getProxyPort()
|
|
132
|
+
|
|
133
|
+
assert.equal(typeof webPid, "number")
|
|
134
|
+
assert.equal(typeof proxyPort, "number")
|
|
135
|
+
|
|
136
|
+
daemon.removeControlSocket = async () => { throw new Error("injected unlink failure") }
|
|
137
|
+
|
|
138
|
+
await assert.rejects(
|
|
139
|
+
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
140
|
+
/control socket unlink failed: injected unlink failure/
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
assert.equal(isProcessAlive(/** @type {number} */ (webPid)), false, "unlink failure must not strand an owned process")
|
|
144
|
+
await assert.rejects(() => fetch(`http://127.0.0.1:${proxyPort}/ping`))
|
|
145
|
+
await assert.rejects(() => fs.stat(statePath), {code: "ENOENT"})
|
|
146
|
+
} finally {
|
|
147
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("direct shutdown closes idle accepted clients and converges", async () => {
|
|
152
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-direct-shutdown-idle-"))
|
|
153
|
+
const socketPath = path.join(root, "control.sock")
|
|
154
|
+
const daemon = new RollbridgeDaemon({config: buildConfig(socketPath), logger: () => {}})
|
|
155
|
+
let idle = /** @type {net.Socket | undefined} */ (undefined)
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await daemon.start()
|
|
159
|
+
idle = net.createConnection(socketPath)
|
|
160
|
+
await once(idle, "connect")
|
|
161
|
+
const idleClosed = once(idle, "close")
|
|
162
|
+
|
|
163
|
+
await daemon.shutdown()
|
|
164
|
+
await idleClosed
|
|
165
|
+
|
|
166
|
+
assert.equal(idle.destroyed, true)
|
|
167
|
+
await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
|
|
168
|
+
} finally {
|
|
169
|
+
idle?.destroy()
|
|
170
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test("shutdown reports cleanup failure and still retires the targeted endpoint", async () => {
|
|
175
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-failure-"))
|
|
176
|
+
const socketPath = path.join(root, "control.sock")
|
|
177
|
+
const statePath = path.join(root, "state-directory")
|
|
178
|
+
|
|
179
|
+
await fs.mkdir(statePath)
|
|
180
|
+
|
|
181
|
+
const config = normalizeConfig({
|
|
182
|
+
...rawConfig(socketPath),
|
|
183
|
+
statePath
|
|
184
|
+
})
|
|
185
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await daemon.start()
|
|
189
|
+
|
|
190
|
+
await assert.rejects(
|
|
191
|
+
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
192
|
+
/directory|EISDIR/i
|
|
193
|
+
)
|
|
194
|
+
await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
|
|
195
|
+
} finally {
|
|
196
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test("shutdown does not turn an owned-resource stop rejection into success", async () => {
|
|
201
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-stop-failure-"))
|
|
202
|
+
const socketPath = path.join(root, "control.sock")
|
|
203
|
+
const config = buildConfig(socketPath)
|
|
204
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
205
|
+
let restoreStop
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
await daemon.start()
|
|
209
|
+
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
210
|
+
|
|
211
|
+
const release = daemon.activeRelease
|
|
212
|
+
|
|
213
|
+
assert.ok(release)
|
|
214
|
+
const originalStop = release.stop.bind(release)
|
|
215
|
+
|
|
216
|
+
restoreStop = originalStop
|
|
217
|
+
release.stop = async () => { throw new Error("owned release stop failed") }
|
|
218
|
+
|
|
219
|
+
await assert.rejects(
|
|
220
|
+
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
221
|
+
/Shutdown failed to stop 1 owned resource/
|
|
222
|
+
)
|
|
223
|
+
} finally {
|
|
224
|
+
if (restoreStop) await restoreStop()
|
|
225
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test("shutdown of an already-stopped endpoint fails explicitly", async () => {
|
|
230
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-missing-"))
|
|
231
|
+
const socketPath = path.join(root, "missing.sock")
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
await assert.rejects(
|
|
235
|
+
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
236
|
+
(error) => Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
237
|
+
)
|
|
238
|
+
} finally {
|
|
239
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @param {string} socketPath - Control socket path.
|
|
245
|
+
* @param {{companion?: Record<string, import("../src/json.js").JsonValue>}} [options] - Optional companion process.
|
|
246
|
+
* @returns {import("../src/config.js").RollbridgeConfig} Normalized config.
|
|
247
|
+
*/
|
|
248
|
+
function buildConfig(socketPath, {companion} = {}) {
|
|
249
|
+
return normalizeConfig({
|
|
250
|
+
...rawConfig(socketPath),
|
|
251
|
+
...(companion ? {processes: [companion, ...rawConfig(socketPath).processes]} : {})
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @param {string} socketPath - Control socket path.
|
|
257
|
+
* @returns {{application: string, control: {path: string}, processes: Record<string, import("../src/json.js").JsonValue>[], proxy: {forceStopTimeoutMs: number, host: string, port: number}}} Raw config.
|
|
258
|
+
*/
|
|
259
|
+
function rawConfig(socketPath) {
|
|
260
|
+
return {
|
|
261
|
+
application: socketPath.endsWith("unrelated.sock") ? "shutdown-unrelated" : "shutdown-target",
|
|
262
|
+
control: {path: socketPath},
|
|
263
|
+
processes: [{
|
|
264
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
|
|
265
|
+
health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
|
|
266
|
+
id: "web",
|
|
267
|
+
policy: "proxied",
|
|
268
|
+
port: {from: 0, to: 0}
|
|
269
|
+
}],
|
|
270
|
+
proxy: {forceStopTimeoutMs: 1000, host: "127.0.0.1", port: 0}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* @param {string} filePath - File to await without polling.
|
|
276
|
+
* @returns {Promise<void>} Resolves when the file appears.
|
|
277
|
+
*/
|
|
278
|
+
async function waitForFile(filePath) {
|
|
279
|
+
const watcher = fs.watch(path.dirname(filePath))
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
for await (const event of watcher) {
|
|
283
|
+
if (event.filename === path.basename(filePath)) return
|
|
284
|
+
}
|
|
285
|
+
} finally {
|
|
286
|
+
await watcher.return?.()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
throw new Error(`Watcher ended before ${filePath} appeared`)
|
|
290
|
+
}
|