rollbridge 0.1.18 → 0.1.21
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 +15 -0
- package/docs/cli.md +17 -2
- package/package.json +1 -1
- package/src/cli.js +11 -3
- package/src/daemon.js +172 -26
- package/src/managed-process.js +28 -20
- package/src/release-group.js +5 -0
- package/test/daemon-bootstrap.test.js +32 -0
- package/test/rollbridge.test.js +8 -0
- package/test/shutdown-completion.test.js +327 -0
package/README.md
CHANGED
|
@@ -412,6 +412,15 @@ rollbridge daemon --config /srv/ticket-server/rollbridge.js \
|
|
|
412
412
|
--boot-attestation sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
413
413
|
```
|
|
414
414
|
|
|
415
|
+
External supervisors that need to replace a foreground owner without waiting
|
|
416
|
+
for its workers to drain add `--takeover-owner`. The candidate starts and
|
|
417
|
+
health-checks the exact release first. Only then does it retire the accepted
|
|
418
|
+
owner's proxy/control listeners; the retired daemon keeps its already-accepted
|
|
419
|
+
workers until their ordinary drain completes while the attested replacement
|
|
420
|
+
binds the stable listeners. Candidate bootstrap failure leaves the accepted
|
|
421
|
+
owner untouched. This is opt-in; ordinary daemon bootstrap and `shutdown` keep
|
|
422
|
+
their existing behavior.
|
|
423
|
+
|
|
415
424
|
The four bootstrap inputs are all-or-nothing and use absolute config/release
|
|
416
425
|
paths. Rollbridge binds its proxy, activates the release through the normal
|
|
417
426
|
deploy path, then exposes the control socket and stays foreground. A failed
|
|
@@ -509,6 +518,12 @@ Shut down the daemon and managed processes:
|
|
|
509
518
|
rollbridge shutdown --config rollbridge.js
|
|
510
519
|
```
|
|
511
520
|
|
|
521
|
+
A successful shutdown response is emitted only after the targeted control
|
|
522
|
+
endpoint has stopped accepting connections and been removed, owned processes
|
|
523
|
+
and the proxy have stopped, and persistent state cleanup has finished. It is
|
|
524
|
+
therefore safe to start or ensure a replacement daemon immediately, without a
|
|
525
|
+
delay or retry loop. Cleanup failure or an already-missing daemon exits non-zero.
|
|
526
|
+
|
|
512
527
|
Prepare a first Rollbridge deploy by recovering Rollbridge-managed orphans and
|
|
513
528
|
stopping configured legacy processes:
|
|
514
529
|
|
package/docs/cli.md
CHANGED
|
@@ -23,7 +23,7 @@ process-policy details.
|
|
|
23
23
|
## `daemon`
|
|
24
24
|
|
|
25
25
|
```
|
|
26
|
-
rollbridge daemon [--config <path>]
|
|
26
|
+
rollbridge daemon [--config <path>] [--takeover-owner]
|
|
27
27
|
[--release-path <path> --release-id <id> --revision <sha>
|
|
28
28
|
[--boot-attestation <sha256:digest>]]
|
|
29
29
|
```
|
|
@@ -64,6 +64,13 @@ identity. Rollbridge does not calculate or interpret the digest.
|
|
|
64
64
|
With no release options, daemon behavior is unchanged: it starts listener-only
|
|
65
65
|
and waits for control-socket deployments.
|
|
66
66
|
|
|
67
|
+
`--takeover-owner` requires the complete bootstrap tuple. It bootstraps and
|
|
68
|
+
health-checks the replacement before sending the current daemon the private
|
|
69
|
+
retirement command. Retirement stops stable listeners and new work promptly,
|
|
70
|
+
but does not wait for owned workers' normal drain before the replacement binds
|
|
71
|
+
the proxy and control socket. A bootstrap failure occurs before retirement, so
|
|
72
|
+
the previously accepted owner remains available.
|
|
73
|
+
|
|
67
74
|
## `ensure-daemon`
|
|
68
75
|
|
|
69
76
|
```
|
|
@@ -292,7 +299,15 @@ rollbridge shutdown [--config <path>]
|
|
|
292
299
|
|
|
293
300
|
Stops all managed processes (services, singletons, and releases), closes the
|
|
294
301
|
proxy and control socket, removes the socket file, and prints
|
|
295
|
-
`{"status": "success", "message": "shutdown"}`.
|
|
302
|
+
`{"status": "success", "message": "shutdown"}`. The success response is a
|
|
303
|
+
completion signal, not an early acknowledgement: before sending it, Rollbridge
|
|
304
|
+
stops accepting new control connections, removes the targeted socket, finishes
|
|
305
|
+
owned-process and proxy cleanup, and finalizes persistent state. A caller may
|
|
306
|
+
immediately start or ensure a replacement daemon after the command returns.
|
|
307
|
+
|
|
308
|
+
If cleanup fails, the command exits non-zero with the daemon's error instead of
|
|
309
|
+
reporting success. Calling `shutdown` when no daemon owns the configured control
|
|
310
|
+
socket also remains an explicit connection error.
|
|
296
311
|
|
|
297
312
|
## `validate`
|
|
298
313
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -36,6 +36,7 @@ export async function runCli(argv) {
|
|
|
36
36
|
.option("--release-id <id>", "Bootstrap release id (requires --config, --release-path, and --revision)")
|
|
37
37
|
.option("--revision <sha>", "Bootstrap revision (requires --config, --release-path, and --release-id)")
|
|
38
38
|
.option("--boot-attestation <digest>", "Opaque bootstrap ownership attestation (requires the complete bootstrap release tuple)")
|
|
39
|
+
.option("--takeover-owner", "Boot and health-check before retiring the current external owner")
|
|
39
40
|
.action(async (options) => {
|
|
40
41
|
const bootstrap = await validateDaemonBootstrapOptions(options)
|
|
41
42
|
const configPath = await resolveConfigPath(options.config)
|
|
@@ -43,7 +44,9 @@ export async function runCli(argv) {
|
|
|
43
44
|
const runtime = await loadDaemonRuntimeIdentity(process.env.ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST)
|
|
44
45
|
const daemon = new RollbridgeDaemon({bootstrap, config, configPath, runtime})
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
if (options.takeoverOwner && (!bootstrap || !bootstrap.attestation)) throw new Error("Daemon --takeover-owner requires the complete bootstrap release tuple and --boot-attestation.")
|
|
48
|
+
|
|
49
|
+
if (!options.takeoverOwner) await daemon.start({exposeControl: !bootstrap})
|
|
47
50
|
|
|
48
51
|
const shutdown = async () => {
|
|
49
52
|
await daemon.shutdown()
|
|
@@ -56,10 +59,15 @@ export async function runCli(argv) {
|
|
|
56
59
|
if (bootstrap) {
|
|
57
60
|
try {
|
|
58
61
|
await daemon.deploy(bootstrap)
|
|
59
|
-
|
|
62
|
+
if (options.takeoverOwner) {
|
|
63
|
+
await sendControlCommand({command: {attestation: bootstrap.attestation, command: "retire-owner"}, path: config.control.path})
|
|
64
|
+
await daemon.start({reportOrphans: false})
|
|
65
|
+
} else {
|
|
66
|
+
await daemon.exposeControl()
|
|
67
|
+
}
|
|
60
68
|
} catch {
|
|
61
69
|
daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error"})
|
|
62
|
-
await daemon.shutdown()
|
|
70
|
+
if (!options.takeoverOwner) await daemon.shutdown()
|
|
63
71
|
process.exitCode = 1
|
|
64
72
|
return
|
|
65
73
|
}
|
package/src/daemon.js
CHANGED
|
@@ -56,11 +56,16 @@ 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)
|
|
64
|
+
this.persistenceEnabled = false
|
|
63
65
|
this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
|
|
66
|
+
this.shutdownPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
67
|
+
this.retirementPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
68
|
+
this.controlClosePromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
64
69
|
this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
|
|
65
70
|
// Still-alive managed processes left by a previous daemon (from statePath), captured at
|
|
66
71
|
// startup and surfaced in status(). The daemon cannot re-manage them, only report them.
|
|
@@ -71,11 +76,11 @@ export default class RollbridgeDaemon {
|
|
|
71
76
|
|
|
72
77
|
/**
|
|
73
78
|
* Starts daemon listeners.
|
|
74
|
-
* @param {{exposeControl?: boolean}} [options] -
|
|
79
|
+
* @param {{exposeControl?: boolean, reportOrphans?: boolean}} [options] - Listener startup options.
|
|
75
80
|
* @returns {Promise<void>} Resolves when the requested listeners are ready.
|
|
76
81
|
*/
|
|
77
|
-
async start({exposeControl = true} = {}) {
|
|
78
|
-
await this.reportOrphans()
|
|
82
|
+
async start({exposeControl = true, reportOrphans = true} = {}) {
|
|
83
|
+
if (reportOrphans) await this.reportOrphans()
|
|
79
84
|
await this.startProxy()
|
|
80
85
|
if (exposeControl) await this.exposeControl()
|
|
81
86
|
}
|
|
@@ -247,9 +252,17 @@ export default class RollbridgeDaemon {
|
|
|
247
252
|
* @returns {void}
|
|
248
253
|
*/
|
|
249
254
|
handleControlSocket(socket) {
|
|
255
|
+
this.controlSockets.add(socket)
|
|
250
256
|
socket.setEncoding("utf8")
|
|
251
257
|
let buffer = ""
|
|
252
258
|
|
|
259
|
+
socket.once("close", () => this.controlSockets.delete(socket))
|
|
260
|
+
socket.on("error", (error) => {
|
|
261
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : null
|
|
262
|
+
|
|
263
|
+
this.logger("control connection error", {code, error: error.message})
|
|
264
|
+
})
|
|
265
|
+
|
|
253
266
|
socket.on("data", (chunk) => {
|
|
254
267
|
buffer += chunk
|
|
255
268
|
let newlineIndex = buffer.indexOf("\n")
|
|
@@ -269,22 +282,34 @@ export default class RollbridgeDaemon {
|
|
|
269
282
|
* @returns {void}
|
|
270
283
|
*/
|
|
271
284
|
handleControlLine(line, socket) {
|
|
272
|
-
|
|
273
|
-
|
|
285
|
+
const closesConnection = isShutdownControlLine(line)
|
|
286
|
+
const respond = (/** @type {Record<string, JsonValue>} */ response) => {
|
|
287
|
+
const payload = `${JSON.stringify(response)}\n`
|
|
288
|
+
|
|
289
|
+
if (closesConnection) {
|
|
290
|
+
socket.end(payload, () => socket.destroy())
|
|
291
|
+
} else if (!socket.destroyed) {
|
|
292
|
+
socket.write(payload)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.executeControlLine(line, socket)
|
|
297
|
+
.then((response) => respond({status: "success", ...response}))
|
|
274
298
|
.catch((error) => {
|
|
275
299
|
this.logger("command failed", {error: error instanceof Error ? error.message : String(error)})
|
|
276
|
-
|
|
300
|
+
respond({
|
|
277
301
|
error: error instanceof Error ? error.message : String(error),
|
|
278
302
|
status: "error"
|
|
279
|
-
})
|
|
303
|
+
})
|
|
280
304
|
})
|
|
281
305
|
}
|
|
282
306
|
|
|
283
307
|
/**
|
|
284
308
|
* @param {string} line - JSON command line.
|
|
309
|
+
* @param {net.Socket} [controlSocket] - Requesting control connection, used only for shutdown completion.
|
|
285
310
|
* @returns {Promise<Record<string, JsonValue>>} Command response.
|
|
286
311
|
*/
|
|
287
|
-
async executeControlLine(line) {
|
|
312
|
+
async executeControlLine(line, controlSocket) {
|
|
288
313
|
const command = JSON.parse(line)
|
|
289
314
|
|
|
290
315
|
if (!command || typeof command !== "object") {
|
|
@@ -327,15 +352,21 @@ export default class RollbridgeDaemon {
|
|
|
327
352
|
}
|
|
328
353
|
|
|
329
354
|
if (commandName === "shutdown") {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
})
|
|
355
|
+
// Stop accepting new control connections before cleanup, but keep this requesting
|
|
356
|
+
// connection open as the completion channel. Waiting for all control connections here
|
|
357
|
+
// would deadlock: server.close() includes the socket awaiting this response.
|
|
358
|
+
await this.shutdown({completionSocket: controlSocket, waitForControlConnections: false})
|
|
335
359
|
|
|
336
360
|
return {message: "shutdown"}
|
|
337
361
|
}
|
|
338
362
|
|
|
363
|
+
if (commandName === "retire-owner") {
|
|
364
|
+
const attestation = requiredString(data.attestation, "attestation")
|
|
365
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(attestation)) throw new Error("Owner retirement attestation must use the canonical sha256:<64 lowercase hex> format")
|
|
366
|
+
await this.retireOwner({attestation, completionSocket: controlSocket})
|
|
367
|
+
return {message: "owner retired"}
|
|
368
|
+
}
|
|
369
|
+
|
|
339
370
|
throw new Error(`Unknown command: ${String(commandName)}`)
|
|
340
371
|
}
|
|
341
372
|
|
|
@@ -710,6 +741,7 @@ export default class RollbridgeDaemon {
|
|
|
710
741
|
startStatePersistence() {
|
|
711
742
|
if (!this.statePath) return
|
|
712
743
|
|
|
744
|
+
this.persistenceEnabled = true
|
|
713
745
|
this.persistState()
|
|
714
746
|
this.persistTimer = setInterval(() => this.persistState(), STATE_PERSIST_INTERVAL_MS)
|
|
715
747
|
this.persistTimer.unref?.()
|
|
@@ -721,7 +753,7 @@ export default class RollbridgeDaemon {
|
|
|
721
753
|
* @returns {void}
|
|
722
754
|
*/
|
|
723
755
|
persistState() {
|
|
724
|
-
if (!this.statePath || this.stopping) return
|
|
756
|
+
if (!this.statePath || !this.persistenceEnabled || this.stopping) return
|
|
725
757
|
|
|
726
758
|
const statePath = this.statePath
|
|
727
759
|
const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
|
|
@@ -763,30 +795,100 @@ export default class RollbridgeDaemon {
|
|
|
763
795
|
}
|
|
764
796
|
}
|
|
765
797
|
|
|
766
|
-
/**
|
|
767
|
-
|
|
768
|
-
|
|
798
|
+
/**
|
|
799
|
+
* Stops proxy, control socket, and child processes.
|
|
800
|
+
* @param {{completionSocket?: net.Socket, waitForControlConnections?: boolean}} [options] - Shutdown connection behavior.
|
|
801
|
+
* @returns {Promise<void>} Resolves when owned resources are stopped (and, by default, control connections close).
|
|
802
|
+
*/
|
|
803
|
+
async shutdown({completionSocket, waitForControlConnections = true} = {}) {
|
|
804
|
+
if (!this.shutdownPromise) this.shutdownPromise = this.performShutdown(completionSocket)
|
|
805
|
+
|
|
806
|
+
await this.shutdownPromise
|
|
807
|
+
if (waitForControlConnections && this.controlClosePromise) await this.controlClosePromise
|
|
808
|
+
}
|
|
769
809
|
|
|
810
|
+
/**
|
|
811
|
+
* Relinquishes stable listeners promptly while retaining draining children under
|
|
812
|
+
* this daemon until their normal stop contract completes.
|
|
813
|
+
* @param {{attestation: string, completionSocket?: net.Socket}} options - Attested handoff request.
|
|
814
|
+
* @returns {Promise<void>} Resolves once a replacement can exclusively bind listeners.
|
|
815
|
+
*/
|
|
816
|
+
async retireOwner({attestation, completionSocket}) {
|
|
817
|
+
if (this.retirementPromise) return await this.retirementPromise
|
|
818
|
+
this.retirementPromise = this.performOwnerRetirement(attestation, completionSocket)
|
|
819
|
+
return await this.retirementPromise
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* @param {string} attestation - Replacement boot attestation.
|
|
824
|
+
* @param {net.Socket | undefined} completionSocket - Requesting handoff connection.
|
|
825
|
+
* @returns {Promise<void>} Resolves after quiesce and listener release.
|
|
826
|
+
*/
|
|
827
|
+
async performOwnerRetirement(attestation, completionSocket) {
|
|
828
|
+
this.stopping = true
|
|
829
|
+
if (this.persistTimer) {
|
|
830
|
+
clearInterval(this.persistTimer)
|
|
831
|
+
this.persistTimer = undefined
|
|
832
|
+
}
|
|
833
|
+
this.persistenceEnabled = false
|
|
834
|
+
if (this.pendingWrite) await this.pendingWrite
|
|
835
|
+
this.controlClosePromise = this.closeServer(this.controlServer)
|
|
836
|
+
for (const socket of this.controlSockets) if (socket !== completionSocket) socket.destroy()
|
|
837
|
+
await Promise.all([
|
|
838
|
+
...[...this.services.values()].map((processInstance) => processInstance.quiesce()),
|
|
839
|
+
...[...this.singletons.values()].map((processInstance) => processInstance.quiesce()),
|
|
840
|
+
...[...this.startingReleases].map((release) => release.quiesce()),
|
|
841
|
+
...[...this.releases.values()].map((release) => release.quiesce())
|
|
842
|
+
])
|
|
843
|
+
await this.removeControlSocket()
|
|
844
|
+
void this.closeServer(this.proxyServer)
|
|
845
|
+
void Promise.allSettled([
|
|
846
|
+
...[...this.services.values()].map((processInstance) => processInstance.stop()),
|
|
847
|
+
...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
|
|
848
|
+
...[...this.startingReleases].map((release) => release.stop()),
|
|
849
|
+
...[...this.releases.values()].map((release) => release.stop())
|
|
850
|
+
])
|
|
851
|
+
this.logger("external owner retired", {attestation, status: "draining"})
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* @param {net.Socket | undefined} completionSocket - Requester retained for the final response.
|
|
856
|
+
* @returns {Promise<void>} Retires listeners and cleans up every daemon-owned resource.
|
|
857
|
+
*/
|
|
858
|
+
async performShutdown(completionSocket) {
|
|
770
859
|
this.stopping = true
|
|
860
|
+
const cleanupErrors = /** @type {Error[]} */ ([])
|
|
861
|
+
|
|
862
|
+
// server.close() stops new connections synchronously. Unlink immediately afterward so a
|
|
863
|
+
// replacement can bind as soon as cleanup completes; existing connections remain usable for
|
|
864
|
+
// the shutdown completion/error response.
|
|
865
|
+
this.controlClosePromise = this.closeServer(this.controlServer)
|
|
866
|
+
|
|
867
|
+
for (const socket of this.controlSockets) {
|
|
868
|
+
if (socket !== completionSocket) socket.destroy()
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
await captureShutdownError(cleanupErrors, "control socket unlink", () => this.removeControlSocket())
|
|
771
872
|
|
|
772
873
|
if (this.persistTimer) {
|
|
773
874
|
clearInterval(this.persistTimer)
|
|
774
875
|
this.persistTimer = undefined
|
|
775
876
|
}
|
|
776
877
|
|
|
777
|
-
this.proxy.close()
|
|
778
|
-
await Promise.allSettled([
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
await
|
|
878
|
+
await captureShutdownError(cleanupErrors, "proxy close", async () => this.proxy.close())
|
|
879
|
+
const stopResults = await Promise.allSettled([
|
|
880
|
+
...[...this.services.values()].map((processInstance) => processInstance.stop()),
|
|
881
|
+
...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
|
|
882
|
+
...[...this.startingReleases].map((release) => release.stop()),
|
|
883
|
+
...[...this.releases.values()].map((release) => release.stop())
|
|
884
|
+
])
|
|
885
|
+
await captureShutdownError(cleanupErrors, "proxy server close", () => this.closeServer(this.proxyServer))
|
|
785
886
|
|
|
786
887
|
// Wait for any in-flight write first so it can't recreate or overwrite the final state (no
|
|
787
888
|
// new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
|
|
788
889
|
// orphans are not owned by this daemon, so retain their records until they are confirmed gone.
|
|
789
|
-
|
|
890
|
+
await captureShutdownError(cleanupErrors, "persistent state cleanup", async () => {
|
|
891
|
+
if (!this.statePath) return
|
|
790
892
|
if (this.pendingWrite) await this.pendingWrite
|
|
791
893
|
const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
|
|
792
894
|
|
|
@@ -795,7 +897,20 @@ export default class RollbridgeDaemon {
|
|
|
795
897
|
} else {
|
|
796
898
|
await clearState(this.statePath)
|
|
797
899
|
}
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
const stopErrors = stopResults.filter((result) => result.status === "rejected").map((result) => result.reason)
|
|
903
|
+
|
|
904
|
+
if (stopErrors.length > 0) {
|
|
905
|
+
cleanupErrors.push(new AggregateError(stopErrors, `Shutdown failed to stop ${stopErrors.length} owned resource${stopErrors.length === 1 ? "" : "s"}.`))
|
|
798
906
|
}
|
|
907
|
+
|
|
908
|
+
if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, cleanupErrors.map((error) => error.message).join("; "))
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** @returns {Promise<void>} Removes the configured control socket path. */
|
|
912
|
+
async removeControlSocket() {
|
|
913
|
+
await fs.rm(this.config.control.path, {force: true})
|
|
799
914
|
}
|
|
800
915
|
|
|
801
916
|
/**
|
|
@@ -856,6 +971,37 @@ function stringOrUndefined(value) {
|
|
|
856
971
|
return value
|
|
857
972
|
}
|
|
858
973
|
|
|
974
|
+
/**
|
|
975
|
+
* @param {string} line - Raw control line.
|
|
976
|
+
* @returns {boolean} Whether the line requests shutdown and needs a terminal response connection.
|
|
977
|
+
*/
|
|
978
|
+
function isShutdownControlLine(line) {
|
|
979
|
+
try {
|
|
980
|
+
const command = JSON.parse(line)
|
|
981
|
+
|
|
982
|
+
return Boolean(command && typeof command === "object" && ["retire-owner", "shutdown"].includes(command.command))
|
|
983
|
+
} catch {
|
|
984
|
+
return false
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Runs one shutdown cleanup step and records a labeled failure without skipping later cleanup.
|
|
990
|
+
* @param {Error[]} errors - Accumulated cleanup errors.
|
|
991
|
+
* @param {string} label - Non-secret cleanup step name.
|
|
992
|
+
* @param {() => Promise<void>} operation - Cleanup operation.
|
|
993
|
+
* @returns {Promise<void>} Resolves after the operation succeeds or its failure is recorded.
|
|
994
|
+
*/
|
|
995
|
+
async function captureShutdownError(errors, label, operation) {
|
|
996
|
+
try {
|
|
997
|
+
await operation()
|
|
998
|
+
} catch (error) {
|
|
999
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
1000
|
+
|
|
1001
|
+
errors.push(new Error(`${label} failed: ${reason}`, {cause: error}))
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
859
1005
|
const SECRET_BEARING_STATE_KEYS = new Set(["children", "command", "cwd", "env", "environment", "logs", "output"])
|
|
860
1006
|
|
|
861
1007
|
/**
|
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.quiescePromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
65
66
|
this.restartTimer = undefined
|
|
66
67
|
this.child = undefined
|
|
67
68
|
this.exitPromise = undefined
|
|
@@ -78,6 +79,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
78
79
|
if (this.child) return
|
|
79
80
|
|
|
80
81
|
this.intentionalStop = false
|
|
82
|
+
this.quiescePromise = undefined
|
|
81
83
|
this.exitCode = undefined
|
|
82
84
|
this.exitSignal = undefined
|
|
83
85
|
this.state = "starting"
|
|
@@ -338,36 +340,22 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
338
340
|
* @returns {Promise<void>} Resolves when stopped.
|
|
339
341
|
*/
|
|
340
342
|
async stop(options = {}) {
|
|
341
|
-
this.
|
|
342
|
-
this.
|
|
343
|
-
|
|
344
|
-
if (this.restartTimer) {
|
|
345
|
-
clearTimeout(this.restartTimer)
|
|
346
|
-
this.restartTimer = undefined
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const child = this.child
|
|
343
|
+
const pgid = this.child?.pid ?? this.pid
|
|
344
|
+
const exitPromise = this.exitPromise
|
|
345
|
+
await this.quiesce()
|
|
350
346
|
|
|
351
|
-
if (!
|
|
347
|
+
if (!pgid) {
|
|
352
348
|
this.state = "stopped"
|
|
353
349
|
return
|
|
354
350
|
}
|
|
355
351
|
|
|
356
|
-
const
|
|
357
|
-
const exitPromise = this.exitPromise
|
|
358
|
-
|
|
359
|
-
this.state = "stopping"
|
|
360
|
-
|
|
361
|
-
const {drainCommand, drainTimeoutMs, quietCommand, stopCommand} = this.lifecycle
|
|
352
|
+
const {drainCommand, drainTimeoutMs, stopCommand} = this.lifecycle
|
|
362
353
|
|
|
363
354
|
const hookTimeoutMs = this.hookTimeoutMs()
|
|
364
355
|
|
|
365
|
-
// 1. Quiesce: tell the process to stop accepting new work.
|
|
366
|
-
if (quietCommand) await this.runHook(quietCommand, hookTimeoutMs, "quiet command")
|
|
367
|
-
|
|
368
356
|
// 2. Drain: let in-flight work finish, bounded by drainTimeoutMs (0 skips the step). A
|
|
369
357
|
// drainCommand blocks until drained; otherwise wait for the process to exit on its own.
|
|
370
|
-
if (this.
|
|
358
|
+
if (this.processGroupExists(pgid) && drainTimeoutMs > 0) {
|
|
371
359
|
if (drainCommand) await this.runHook(drainCommand, drainTimeoutMs, "drain command")
|
|
372
360
|
else await this.waitForExit(drainTimeoutMs)
|
|
373
361
|
}
|
|
@@ -391,6 +379,26 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
391
379
|
this.state = "stopped"
|
|
392
380
|
}
|
|
393
381
|
|
|
382
|
+
/** @returns {Promise<void>} Stops restarts and waits until the process has stopped accepting new work. */
|
|
383
|
+
async quiesce() {
|
|
384
|
+
if (this.quiescePromise) return await this.quiescePromise
|
|
385
|
+
this.quiescePromise = (async () => {
|
|
386
|
+
this.intentionalStop = true
|
|
387
|
+
this.clearMemoryMonitor()
|
|
388
|
+
if (this.restartTimer) {
|
|
389
|
+
clearTimeout(this.restartTimer)
|
|
390
|
+
this.restartTimer = undefined
|
|
391
|
+
}
|
|
392
|
+
if (!this.child?.pid) {
|
|
393
|
+
this.state = "stopped"
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
this.state = "stopping"
|
|
397
|
+
if (this.lifecycle.quietCommand) await this.runHook(this.lifecycle.quietCommand, this.hookTimeoutMs(), "quiet command")
|
|
398
|
+
})()
|
|
399
|
+
return await this.quiescePromise
|
|
400
|
+
}
|
|
401
|
+
|
|
394
402
|
/** @returns {number} Timeout used for lifecycle hooks. */
|
|
395
403
|
hookTimeoutMs() {
|
|
396
404
|
if (this.stopTimeoutMs === "indefinite") return 30000
|
package/src/release-group.js
CHANGED
|
@@ -403,6 +403,11 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
403
403
|
this.stoppedAt = new Date().toISOString()
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
/** @returns {Promise<void>} Quiesces every release process without waiting for its drain. */
|
|
407
|
+
async quiesce() {
|
|
408
|
+
await Promise.all([...this.processes.values()].map((processInstance) => processInstance.quiesce()))
|
|
409
|
+
}
|
|
410
|
+
|
|
406
411
|
/** @returns {ReleaseStatus} Status payload. */
|
|
407
412
|
status() {
|
|
408
413
|
return {
|
|
@@ -85,6 +85,38 @@ test("daemon bootstrap activates the exact release through the foreground daemon
|
|
|
85
85
|
}
|
|
86
86
|
})
|
|
87
87
|
|
|
88
|
+
test("failed takeover bootstrap preserves the previously accepted owner", async () => {
|
|
89
|
+
const fixture = await createFixture()
|
|
90
|
+
const accepted = spawnDaemon(fixture, {attestation: firstAttestation, releaseId: "accepted", revision: "accepted123"})
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await waitForLog(accepted, "control socket listening")
|
|
94
|
+
const badConfig = JSON.parse((await fs.readFile(fixture.configPath, "utf8")).replace(/^module\.exports = /, ""))
|
|
95
|
+
badConfig.processes[0].health.path = "/never-ready"
|
|
96
|
+
badConfig.processes[0].health.timeoutMs = 100
|
|
97
|
+
await fs.writeFile(fixture.configPath, `module.exports = ${JSON.stringify(badConfig, null, 2)}\n`)
|
|
98
|
+
|
|
99
|
+
const result = await runDaemon([
|
|
100
|
+
"--config", fixture.configPath,
|
|
101
|
+
"--release-path", fixture.root,
|
|
102
|
+
"--release-id", "candidate",
|
|
103
|
+
"--revision", "candidate123",
|
|
104
|
+
"--boot-attestation", secondAttestation,
|
|
105
|
+
"--takeover-owner"
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
assert.notEqual(result.code, 0)
|
|
109
|
+
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
110
|
+
assert.equal(status.activeReleaseId, "accepted")
|
|
111
|
+
assert.ok(status.bootstrap && typeof status.bootstrap === "object" && !Array.isArray(status.bootstrap))
|
|
112
|
+
assert.equal(status.bootstrap.attestation, firstAttestation)
|
|
113
|
+
} finally {
|
|
114
|
+
accepted.kill("SIGTERM")
|
|
115
|
+
if (accepted.exitCode === null) await once(accepted, "exit")
|
|
116
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
88
120
|
test("daemon bootstrap does not expose control deploys until activation completes", async () => {
|
|
89
121
|
const fixture = await createFixture({healthGate: true, healthTimeoutMs: 60000})
|
|
90
122
|
const started = waitForFile(fixture.startedPath)
|
package/test/rollbridge.test.js
CHANGED
|
@@ -855,6 +855,14 @@ test("a process over its memory limit is restarted with reason memory", {skip: p
|
|
|
855
855
|
assert.equal(hog.lastStartReason, "memory")
|
|
856
856
|
assert.equal(typeof hog.lastMemoryRestartAt, "string")
|
|
857
857
|
|
|
858
|
+
// Keep the replacement alive long enough to observe its next monitor sample. The fixture
|
|
859
|
+
// remains over the configured limit after every launch, otherwise it can restart again and
|
|
860
|
+
// clear rssBytes/children before this polling loop observes them on slower CI runners.
|
|
861
|
+
const hogProcess = daemon.activeRelease?.processes.get("hog")
|
|
862
|
+
|
|
863
|
+
assert.ok(hogProcess?.memory)
|
|
864
|
+
hogProcess.memory.limitBytes = Number.MAX_SAFE_INTEGER
|
|
865
|
+
|
|
858
866
|
// rssBytes is sampled on the monitor's interval; wait for a measurement of the running process.
|
|
859
867
|
await waitFor(() => {
|
|
860
868
|
const rssBytes = activeProcessStatus(daemon, "hog")?.rssBytes
|
|
@@ -0,0 +1,327 @@
|
|
|
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("external-owner retirement releases listeners before a long-draining companion exits", async () => {
|
|
119
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-"))
|
|
120
|
+
const socketPath = path.join(root, "rollbridge.sock")
|
|
121
|
+
const gatePath = path.join(root, "retire.fifo")
|
|
122
|
+
const stoppingPath = path.join(root, "stopping")
|
|
123
|
+
const gate = spawn("mkfifo", [gatePath])
|
|
124
|
+
assert.equal((await once(gate, "exit"))[0], 0)
|
|
125
|
+
const config = buildConfig(socketPath, {companion: {
|
|
126
|
+
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
|
|
127
|
+
id: "worker",
|
|
128
|
+
lifecycle: {drainCommand: `read released < ${JSON.stringify(gatePath)}`, drainTimeoutMs: 60000, quietCommand: `printf stopping > ${JSON.stringify(stoppingPath)}`},
|
|
129
|
+
policy: "companion"
|
|
130
|
+
}})
|
|
131
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
132
|
+
let replacement
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await daemon.start()
|
|
136
|
+
await daemon.deploy({releaseId: "old", releasePath: root, revision: "old"})
|
|
137
|
+
const retirement = sendControlCommand({command: {attestation: `sha256:${"a".repeat(64)}`, command: "retire-owner"}, path: socketPath})
|
|
138
|
+
const response = await retirement
|
|
139
|
+
assert.deepEqual(response, {message: "owner retired", status: "success"})
|
|
140
|
+
assert.equal(await fs.readFile(stoppingPath, "utf8"), "stopping", "old worker must stop accepting work before listener takeover")
|
|
141
|
+
|
|
142
|
+
replacement = new RollbridgeDaemon({config, logger: () => {}})
|
|
143
|
+
await replacement.start({reportOrphans: false})
|
|
144
|
+
assert.equal((await sendControlCommand({command: {command: "status"}, path: socketPath})).application, "shutdown-target")
|
|
145
|
+
assert.deepEqual(replacement.status().orphans, [], "intentional retired companions are not replacement orphans")
|
|
146
|
+
assert.equal(daemon.status().releases[0].processes[0].state, "stopping")
|
|
147
|
+
} finally {
|
|
148
|
+
await fs.writeFile(gatePath, "done\n").catch(() => {})
|
|
149
|
+
if (replacement) await replacement.shutdown()
|
|
150
|
+
await daemon.shutdown()
|
|
151
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test("control socket unlink failure is reported only after owned cleanup completes", async () => {
|
|
156
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-unlink-failure-"))
|
|
157
|
+
const socketPath = path.join(root, "control.sock")
|
|
158
|
+
const statePath = path.join(root, "state.json")
|
|
159
|
+
const config = normalizeConfig({...rawConfig(socketPath), statePath})
|
|
160
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await daemon.start()
|
|
164
|
+
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
165
|
+
if (daemon.pendingWrite) await daemon.pendingWrite
|
|
166
|
+
|
|
167
|
+
const webPid = daemon.activeRelease?.getProcess("web")?.pid
|
|
168
|
+
const proxyPort = daemon.getProxyPort()
|
|
169
|
+
|
|
170
|
+
assert.equal(typeof webPid, "number")
|
|
171
|
+
assert.equal(typeof proxyPort, "number")
|
|
172
|
+
|
|
173
|
+
daemon.removeControlSocket = async () => { throw new Error("injected unlink failure") }
|
|
174
|
+
|
|
175
|
+
await assert.rejects(
|
|
176
|
+
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
177
|
+
/control socket unlink failed: injected unlink failure/
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
assert.equal(isProcessAlive(/** @type {number} */ (webPid)), false, "unlink failure must not strand an owned process")
|
|
181
|
+
await assert.rejects(() => fetch(`http://127.0.0.1:${proxyPort}/ping`))
|
|
182
|
+
await assert.rejects(() => fs.stat(statePath), {code: "ENOENT"})
|
|
183
|
+
} finally {
|
|
184
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test("direct shutdown closes idle accepted clients and converges", async () => {
|
|
189
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-direct-shutdown-idle-"))
|
|
190
|
+
const socketPath = path.join(root, "control.sock")
|
|
191
|
+
const daemon = new RollbridgeDaemon({config: buildConfig(socketPath), logger: () => {}})
|
|
192
|
+
let idle = /** @type {net.Socket | undefined} */ (undefined)
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
await daemon.start()
|
|
196
|
+
idle = net.createConnection(socketPath)
|
|
197
|
+
await once(idle, "connect")
|
|
198
|
+
const idleClosed = once(idle, "close")
|
|
199
|
+
|
|
200
|
+
await daemon.shutdown()
|
|
201
|
+
await idleClosed
|
|
202
|
+
|
|
203
|
+
assert.equal(idle.destroyed, true)
|
|
204
|
+
await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
|
|
205
|
+
} finally {
|
|
206
|
+
idle?.destroy()
|
|
207
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test("shutdown reports cleanup failure and still retires the targeted endpoint", async () => {
|
|
212
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-failure-"))
|
|
213
|
+
const socketPath = path.join(root, "control.sock")
|
|
214
|
+
const statePath = path.join(root, "state-directory")
|
|
215
|
+
|
|
216
|
+
await fs.mkdir(statePath)
|
|
217
|
+
|
|
218
|
+
const config = normalizeConfig({
|
|
219
|
+
...rawConfig(socketPath),
|
|
220
|
+
statePath
|
|
221
|
+
})
|
|
222
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
await daemon.start()
|
|
226
|
+
|
|
227
|
+
await assert.rejects(
|
|
228
|
+
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
229
|
+
/directory|EISDIR/i
|
|
230
|
+
)
|
|
231
|
+
await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
|
|
232
|
+
} finally {
|
|
233
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test("shutdown does not turn an owned-resource stop rejection into success", async () => {
|
|
238
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-stop-failure-"))
|
|
239
|
+
const socketPath = path.join(root, "control.sock")
|
|
240
|
+
const config = buildConfig(socketPath)
|
|
241
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
242
|
+
let restoreStop
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
await daemon.start()
|
|
246
|
+
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
247
|
+
|
|
248
|
+
const release = daemon.activeRelease
|
|
249
|
+
|
|
250
|
+
assert.ok(release)
|
|
251
|
+
const originalStop = release.stop.bind(release)
|
|
252
|
+
|
|
253
|
+
restoreStop = originalStop
|
|
254
|
+
release.stop = async () => { throw new Error("owned release stop failed") }
|
|
255
|
+
|
|
256
|
+
await assert.rejects(
|
|
257
|
+
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
258
|
+
/Shutdown failed to stop 1 owned resource/
|
|
259
|
+
)
|
|
260
|
+
} finally {
|
|
261
|
+
if (restoreStop) await restoreStop()
|
|
262
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test("shutdown of an already-stopped endpoint fails explicitly", async () => {
|
|
267
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-missing-"))
|
|
268
|
+
const socketPath = path.join(root, "missing.sock")
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
await assert.rejects(
|
|
272
|
+
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
273
|
+
(error) => Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
274
|
+
)
|
|
275
|
+
} finally {
|
|
276
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {string} socketPath - Control socket path.
|
|
282
|
+
* @param {{companion?: Record<string, import("../src/json.js").JsonValue>}} [options] - Optional companion process.
|
|
283
|
+
* @returns {import("../src/config.js").RollbridgeConfig} Normalized config.
|
|
284
|
+
*/
|
|
285
|
+
function buildConfig(socketPath, {companion} = {}) {
|
|
286
|
+
return normalizeConfig({
|
|
287
|
+
...rawConfig(socketPath),
|
|
288
|
+
...(companion ? {processes: [companion, ...rawConfig(socketPath).processes]} : {})
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {string} socketPath - Control socket path.
|
|
294
|
+
* @returns {{application: string, control: {path: string}, processes: Record<string, import("../src/json.js").JsonValue>[], proxy: {forceStopTimeoutMs: number, host: string, port: number}}} Raw config.
|
|
295
|
+
*/
|
|
296
|
+
function rawConfig(socketPath) {
|
|
297
|
+
return {
|
|
298
|
+
application: socketPath.endsWith("unrelated.sock") ? "shutdown-unrelated" : "shutdown-target",
|
|
299
|
+
control: {path: socketPath},
|
|
300
|
+
processes: [{
|
|
301
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
|
|
302
|
+
health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
|
|
303
|
+
id: "web",
|
|
304
|
+
policy: "proxied",
|
|
305
|
+
port: {from: 0, to: 0}
|
|
306
|
+
}],
|
|
307
|
+
proxy: {forceStopTimeoutMs: 1000, host: "127.0.0.1", port: 0}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {string} filePath - File to await without polling.
|
|
313
|
+
* @returns {Promise<void>} Resolves when the file appears.
|
|
314
|
+
*/
|
|
315
|
+
async function waitForFile(filePath) {
|
|
316
|
+
const watcher = fs.watch(path.dirname(filePath))
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
for await (const event of watcher) {
|
|
320
|
+
if (event.filename === path.basename(filePath)) return
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
await watcher.return?.()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
throw new Error(`Watcher ended before ${filePath} appeared`)
|
|
327
|
+
}
|