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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
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
- this.executeControlLine(line)
273
- .then((response) => socket.write(`${JSON.stringify({status: "success", ...response})}\n`))
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
- socket.write(`${JSON.stringify({
298
+ respond({
277
299
  error: error instanceof Error ? error.message : String(error),
278
300
  status: "error"
279
- })}\n`)
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
- setImmediate(() => {
331
- this.shutdown().catch((error) => {
332
- this.logger("shutdown failed", {error: error instanceof Error ? error.message : String(error)})
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
- /** @returns {Promise<void>} Stops proxy, control socket, and child processes. */
767
- async shutdown() {
768
- if (this.stopping) return
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([...this.services.values()].map((processInstance) => processInstance.stop()))
779
- await Promise.allSettled([...this.singletons.values()].map((processInstance) => processInstance.stop()))
780
- await Promise.allSettled([...this.startingReleases].map((release) => release.stop()))
781
- await Promise.allSettled([...this.releases.values()].map((release) => release.stop()))
782
- await this.closeServer(this.proxyServer)
783
- await this.closeServer(this.controlServer)
784
- await fs.rm(this.config.control.path, {force: true})
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
- if (this.statePath) {
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
+ }