rollbridge 0.1.10 → 0.1.12

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
@@ -543,8 +543,12 @@ so its output goes to the journal (`journalctl -u rollbridge`). Key directives:
543
543
  The daemon is long-lived and survives deploys. **Deploy with
544
544
  `rollbridge deploy` (or `rollbridge deploy --ensure-daemon`), not
545
545
  `systemctl restart`** — pointing `--config` at a stable, daemon-wide file while
546
- release paths are passed per deploy. Use `command -v rollbridge` to find the
547
- absolute CLI path for `ExecStart`.
546
+ release paths are passed per deploy. The daemon reloads compatible process and
547
+ lifecycle config before each deploy, so updated graceful-stop deadlines govern
548
+ the release being retired without interrupting the stable proxy. Listener and
549
+ process-topology changes still require a daemon restart; see
550
+ [`docs/config.md`](docs/config.md#config-reloads). Use `command -v rollbridge`
551
+ to find the absolute CLI path for `ExecStart`.
548
552
 
549
553
  See [`docs/logging.md`](docs/logging.md) for where the daemon's JSON logs go
550
554
  (stdout / journald / the `--daemon-log-path` file) and how to rotate them — the
package/docs/cli.md CHANGED
@@ -72,6 +72,14 @@ traffic to it, then drains and stops the previous release. Prints
72
72
  If the new release fails to start or health-check, the previous release stays
73
73
  active and the command errors.
74
74
 
75
+ Before each deploy, the daemon reloads the config path it was started with.
76
+ Compatible process and lifecycle changes apply to the new release and govern
77
+ how the previous release retires, including updated `nonBlockingDrain`,
78
+ `stopSignal`, `lifecycle`, and `gracefulStopMs` settings. The daemon adopts the
79
+ new config only after the replacement release starts successfully. Changes to
80
+ daemon-owned listeners or process topology fail the deploy with a restart
81
+ instruction; see [Config reloads](config.md#config-reloads).
82
+
75
83
  - `--release-path <path>` (**required**) — path to the prepared release
76
84
  directory; available to process templates as `{{releasePath}}`.
77
85
  - `--release-id <id>` — identifier for the release. Defaults to `--revision`,
package/docs/config.md CHANGED
@@ -17,6 +17,28 @@ export default {
17
17
  }
18
18
  ```
19
19
 
20
+ ## Config reloads
21
+
22
+ The daemon reloads the config file it was started with before every deploy and
23
+ rollback. Compatible changes apply to the replacement release, daemon-wide
24
+ service restart definitions, and the retirement of the previous release. This
25
+ means changes to process commands, environment, health checks, lifecycle hooks,
26
+ `nonBlockingDrain`, stop signals, graceful-stop timeouts, restart policies, and
27
+ memory supervision do not require restarting the stable proxy daemon. Updated
28
+ retirement settings also apply to processes that were started by the previous
29
+ release.
30
+
31
+ The replacement config is adopted only after the new release starts and passes
32
+ its health check. Invalid config, an incompatible change, or a failed release
33
+ leaves the previous config and release active.
34
+
35
+ Settings that own daemon listeners or change the managed-process topology still
36
+ require a daemon restart: `application`, `control`, `statePath`, `proxy.host`,
37
+ `proxy.port`, `proxy.upstreamHost`, and changes to process ids, count, `policy`,
38
+ `deployStrategy`, `replicas`, or `port`. A deploy with one of these changes
39
+ fails before starting the replacement and names the settings that require a
40
+ restart.
41
+
20
42
  ## Top-level fields
21
43
 
22
44
  | Field | Type | Default | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
@@ -27,10 +27,11 @@
27
27
  },
28
28
  "scripts": {
29
29
  "all-checks": "npm run typecheck && npm run lint && npm test",
30
- "lint": "eslint",
30
+ "lint": "npm run eslint && npm run typecheck",
31
31
  "release:patch": "release-patch",
32
32
  "test": "node --test test/*.test.js",
33
- "typecheck": "tsc --noEmit"
33
+ "typecheck": "tsc --noEmit",
34
+ "eslint": "eslint"
34
35
  },
35
36
  "engines": {
36
37
  "node": ">=20"
package/src/cli.js CHANGED
@@ -33,7 +33,7 @@ export async function runCli(argv) {
33
33
  .action(async (options) => {
34
34
  const configPath = await resolveConfigPath(options.config)
35
35
  const config = await loadConfig(configPath)
36
- const daemon = new RollbridgeDaemon({config})
36
+ const daemon = new RollbridgeDaemon({config, configPath})
37
37
 
38
38
  await daemon.start()
39
39
 
package/src/config.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
 
3
3
  import fs from "node:fs/promises"
4
+ import {createHash} from "node:crypto"
5
+ import {createRequire} from "node:module"
4
6
  import os from "node:os"
5
7
  import path from "node:path"
6
8
  import {pathToFileURL} from "node:url"
@@ -27,6 +29,7 @@ import {pathToFileURL} from "node:url"
27
29
 
28
30
  const PROCESS_POLICIES = new Set(["proxied", "companion", "singleton", "service"])
29
31
  const DEFAULT_CONFIG_FILENAMES = ["rollbridge.js"]
32
+ const commonJsRequire = createRequire(import.meta.url)
30
33
 
31
34
  /**
32
35
  * Imports a JavaScript config module without validating it.
@@ -38,7 +41,13 @@ const DEFAULT_CONFIG_FILENAMES = ["rollbridge.js"]
38
41
  */
39
42
  export async function parseConfigFile(configPath) {
40
43
  const absolutePath = path.resolve(configPath)
41
- const moduleNamespace = await import(pathToFileURL(absolutePath).href)
44
+ const configUrl = pathToFileURL(absolutePath)
45
+ const configSource = await fs.readFile(absolutePath)
46
+
47
+ configUrl.searchParams.set("rollbridgeConfig", createHash("sha256").update(configSource).digest("hex"))
48
+ delete commonJsRequire.cache[commonJsRequire.resolve(absolutePath)]
49
+
50
+ const moduleNamespace = await import(configUrl.href)
42
51
  const exported = moduleNamespace.default
43
52
 
44
53
  if (exported === undefined) {
package/src/daemon.js CHANGED
@@ -3,7 +3,9 @@
3
3
  import fs from "node:fs/promises"
4
4
  import http from "node:http"
5
5
  import net from "node:net"
6
+ import {isDeepStrictEqual} from "node:util"
6
7
  import httpProxy from "http-proxy"
8
+ import {loadConfig} from "./config.js"
7
9
  import EventLog from "./event-log.js"
8
10
  import ReleaseGroup from "./release-group.js"
9
11
  import {clearState, isProcessAlive, liveProcesses, readState, writeState} from "./state-store.js"
@@ -23,10 +25,12 @@ export default class RollbridgeDaemon {
23
25
  /**
24
26
  * @param {object} args - Options.
25
27
  * @param {import("./config.js").RollbridgeConfig} args.config - Rollbridge config.
28
+ * @param {string} [args.configPath] - Config file path to reload before deploys.
26
29
  * @param {(message: string, data?: Record<string, JsonValue>) => void} [args.logger] - Logger.
27
30
  */
28
- constructor({config, logger}) {
31
+ constructor({config, configPath, logger}) {
29
32
  this.config = config
33
+ this.configPath = configPath
30
34
  this.eventLog = new EventLog(EVENT_HISTORY_LIMIT)
31
35
 
32
36
  const baseLogger = logger || ((message, data = {}) => console.log(JSON.stringify({at: new Date().toISOString(), data, message})))
@@ -319,9 +323,13 @@ export default class RollbridgeDaemon {
319
323
  async deploy({releaseId, releasePath, revision}) {
320
324
  if (this.stopping) throw new Error("Rollbridge is shutting down")
321
325
 
326
+ const nextConfig = this.configPath ? await loadConfig(this.configPath) : this.config
327
+
328
+ this.assertReloadCompatible(nextConfig)
329
+
322
330
  const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
323
331
  const release = new ReleaseGroup({
324
- config: this.config,
332
+ config: nextConfig,
325
333
  logger: this.logger,
326
334
  releaseId: newReleaseId,
327
335
  releasePath,
@@ -343,6 +351,7 @@ export default class RollbridgeDaemon {
343
351
 
344
352
  const previousRelease = this.activeRelease
345
353
 
354
+ this.config = nextConfig
346
355
  this.releases.set(release.releaseId, release)
347
356
  release.activate()
348
357
  this.activeRelease = release
@@ -352,7 +361,7 @@ export default class RollbridgeDaemon {
352
361
  await this.replaceSingletons(release)
353
362
 
354
363
  if (previousRelease) {
355
- void this.drainAndPrune(previousRelease)
364
+ void this.drainAndPrune(previousRelease, nextConfig)
356
365
  }
357
366
 
358
367
  this.persistState()
@@ -363,6 +372,45 @@ export default class RollbridgeDaemon {
363
372
  }
364
373
  }
365
374
 
375
+ /**
376
+ * Rejects config changes that require rebinding daemon-owned resources or changing process topology.
377
+ * @param {import("./config.js").RollbridgeConfig} nextConfig - Freshly loaded config.
378
+ * @returns {void}
379
+ */
380
+ assertReloadCompatible(nextConfig) {
381
+ /** @type {string[]} */
382
+ const restartRequired = []
383
+
384
+ if (nextConfig.application !== this.config.application) restartRequired.push("application")
385
+ if (!isDeepStrictEqual(nextConfig.control, this.config.control)) restartRequired.push("control")
386
+ if (nextConfig.statePath !== this.config.statePath) restartRequired.push("statePath")
387
+
388
+ if (nextConfig.proxy.host !== this.config.proxy.host) restartRequired.push("proxy.host")
389
+ if (nextConfig.proxy.port !== this.config.proxy.port) restartRequired.push("proxy.port")
390
+ if (nextConfig.proxy.upstreamHost !== this.config.proxy.upstreamHost) restartRequired.push("proxy.upstreamHost")
391
+
392
+ if (nextConfig.processes.length !== this.config.processes.length) {
393
+ restartRequired.push("processes")
394
+ } else {
395
+ for (const processConfig of this.config.processes) {
396
+ const nextProcessConfig = nextConfig.processes.find((candidate) => candidate.id === processConfig.id)
397
+
398
+ if (!nextProcessConfig ||
399
+ nextProcessConfig.policy !== processConfig.policy ||
400
+ nextProcessConfig.deployStrategy !== processConfig.deployStrategy ||
401
+ nextProcessConfig.replicas !== processConfig.replicas ||
402
+ !isDeepStrictEqual(nextProcessConfig.port, processConfig.port)) {
403
+ restartRequired.push("processes")
404
+ break
405
+ }
406
+ }
407
+ }
408
+
409
+ if (restartRequired.length > 0) {
410
+ throw new Error(`Config changes to ${restartRequired.join(", ")} cannot be applied live; restart the Rollbridge daemon before deploying.`)
411
+ }
412
+ }
413
+
366
414
  /**
367
415
  * Rolls back to a previously-active release by re-running the deploy flow on its
368
416
  * retained metadata: it re-starts the target release, health-checks it, switches
@@ -418,7 +466,7 @@ export default class RollbridgeDaemon {
418
466
  async ensureServices(release, startedServices) {
419
467
  await release.allocatePorts()
420
468
 
421
- for (const processConfig of this.config.processes) {
469
+ for (const processConfig of release.config.processes) {
422
470
  if (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff") continue
423
471
  if (this.services.has(processConfig.id)) continue
424
472
 
@@ -473,20 +521,7 @@ export default class RollbridgeDaemon {
473
521
 
474
522
  const nextDefinition = release.buildProcess(processConfig, {shouldRestart: () => !this.stopping})
475
523
 
476
- service.updateDefinition({
477
- command: nextDefinition.command,
478
- cwd: nextDefinition.cwd,
479
- env: nextDefinition.env,
480
- lifecycle: nextDefinition.lifecycle,
481
- logger: nextDefinition.logger,
482
- memory: nextDefinition.memory,
483
- outputLines: nextDefinition.outputLines,
484
- restart: nextDefinition.restart,
485
- restartDelayMs: nextDefinition.restartDelayMs,
486
- shouldRestart: nextDefinition.shouldRestart,
487
- stopSignal: nextDefinition.stopSignal,
488
- stopTimeoutMs: nextDefinition.stopTimeoutMs
489
- })
524
+ service.updateDefinition(nextDefinition)
490
525
  }
491
526
  }
492
527
 
@@ -611,11 +646,12 @@ export default class RollbridgeDaemon {
611
646
  /**
612
647
  * Drains and stops a retired release in the background, then prunes stopped releases.
613
648
  * @param {ReleaseGroup} release - Release to drain and stop.
649
+ * @param {import("./config.js").RollbridgeConfig} [config] - Refreshed config governing retirement.
614
650
  * @returns {Promise<void>} Resolves once drained, stopped, and pruned.
615
651
  */
616
- async drainAndPrune(release) {
652
+ async drainAndPrune(release, config = this.config) {
617
653
  try {
618
- await release.drainAndStop(this.config.proxy.drainTimeoutMs)
654
+ await release.drainAndStop(config.proxy.drainTimeoutMs, config)
619
655
  this.logger("release drained", {releaseId: release.releaseId})
620
656
  } catch (error) {
621
657
  this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: release.releaseId})
@@ -91,8 +91,9 @@ export default class ReleaseGroup extends EventEmitter {
91
91
  }
92
92
  } catch (error) {
93
93
  this.state = "failed"
94
- this.logStartupFailure(error instanceof Error ? error : String(error))
94
+ this.logStartupFailure(error instanceof Error ? error : String(error), {phase: "before cleanup"})
95
95
  await this.stop()
96
+ this.logStartupFailure(error instanceof Error ? error : String(error), {phase: "after cleanup"})
96
97
  throw error
97
98
  }
98
99
  }
@@ -125,13 +126,15 @@ export default class ReleaseGroup extends EventEmitter {
125
126
  }
126
127
 
127
128
  /**
128
- * Logs process diagnostics before failed startup cleanup stops and removes the release processes.
129
+ * Logs process diagnostics around failed startup cleanup.
129
130
  * @param {Error | string} error - Startup failure.
131
+ * @param {{phase: string}} options - Diagnostic phase.
130
132
  * @returns {void}
131
133
  */
132
- logStartupFailure(error) {
134
+ logStartupFailure(error, {phase}) {
133
135
  this.logger("release startup failed", {
134
136
  error: error instanceof Error ? error.message : error,
137
+ phase,
135
138
  releaseId: this.releaseId
136
139
  })
137
140
 
@@ -143,6 +146,7 @@ export default class ReleaseGroup extends EventEmitter {
143
146
  exitCode: status.exitCode ?? null,
144
147
  exitSignal: status.exitSignal ?? null,
145
148
  logs: status.logs,
149
+ phase,
146
150
  pid: status.pid ?? null,
147
151
  processId: status.id,
148
152
  releaseId: this.releaseId,
@@ -228,6 +232,34 @@ export default class ReleaseGroup extends EventEmitter {
228
232
  })
229
233
  }
230
234
 
235
+ /**
236
+ * Applies refreshed process definitions before retiring this release.
237
+ * @param {import("./config.js").RollbridgeConfig} config - Current deployment config.
238
+ * @returns {void}
239
+ */
240
+ refreshProcessDefinitions(config) {
241
+ this.config = config
242
+ this.handoffServiceIds.clear()
243
+ this.nonBlockingDrainIds.clear()
244
+
245
+ for (const processConfig of config.processes) {
246
+ const instances = this.getProcesses(processConfig.id)
247
+
248
+ for (let index = 0; index < instances.length; index += 1) {
249
+ const instance = instances[index]
250
+ const nextDefinition = this.buildProcess(processConfig, {
251
+ count: processConfig.replicas,
252
+ index,
253
+ instanceId: instance.id
254
+ })
255
+
256
+ instance.process.updateDefinition(nextDefinition)
257
+ if (processConfig.policy === "service" && processConfig.deployStrategy === "handoff") this.handoffServiceIds.add(instance.id)
258
+ if (processConfig.nonBlockingDrain) this.nonBlockingDrainIds.add(instance.id)
259
+ }
260
+ }
261
+ }
262
+
231
263
  /**
232
264
  * @param {import("./config.js").ProcessConfig} processConfig - Process config.
233
265
  * @param {{count: number, index: number}} replica - Replica index and total count.
@@ -321,13 +353,15 @@ export default class ReleaseGroup extends EventEmitter {
321
353
  /**
322
354
  * Starts draining and stops once existing connections close or timeout.
323
355
  * @param {number} timeoutMs - Drain timeout.
356
+ * @param {import("./config.js").RollbridgeConfig} [config] - Refreshed config governing retirement.
324
357
  * @returns {Promise<void>} Resolves when stopped.
325
358
  */
326
- async drainAndStop(timeoutMs) {
359
+ async drainAndStop(timeoutMs, config = this.config) {
327
360
  if (this.state === "stopped") return
328
361
 
329
362
  this.state = "draining"
330
363
  this.drainStartedAt = new Date().toISOString()
364
+ this.refreshProcessDefinitions(config)
331
365
 
332
366
  // Stop nonBlockingDrain processes (e.g. job workers) immediately and in the background, so
333
367
  // their lifecycle drain runs as soon as the release is retired — in parallel with the
@@ -5,7 +5,7 @@ import fs from "node:fs/promises"
5
5
  import os from "node:os"
6
6
  import path from "node:path"
7
7
  import test from "node:test"
8
- import {resolveConfigPath} from "../src/config.js"
8
+ import {loadConfig, resolveConfigPath} from "../src/config.js"
9
9
  import {runCli} from "../src/cli.js"
10
10
 
11
11
  const validConfig = {
@@ -65,6 +65,21 @@ test("resolveConfigPath throws an actionable error when no default config exists
65
65
  }
66
66
  })
67
67
 
68
+ test("loadConfig reloads a changed CommonJS module", async () => {
69
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
70
+ const configPath = await writeConfigModule(dir)
71
+
72
+ try {
73
+ assert.equal((await loadConfig(configPath)).application, "demo")
74
+
75
+ await fs.writeFile(configPath, `module.exports = ${JSON.stringify({...validConfig, application: "updated"}, null, 2)}\n`)
76
+
77
+ assert.equal((await loadConfig(configPath)).application, "updated")
78
+ } finally {
79
+ await fs.rm(dir, {force: true, recursive: true})
80
+ }
81
+ })
82
+
68
83
  test("validate CLI command resolves the default config when --config is omitted", async () => {
69
84
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
70
85
  const originalCwd = process.cwd()
@@ -102,6 +102,96 @@ test("failed health check leaves the previous release active", async () => {
102
102
  }
103
103
  })
104
104
 
105
+ test("deploy reloads process config and retires the previous worker with the refreshed timeout", async () => {
106
+ const fixture = await createFixture({nonBlockingDrainWorker: true, workerStopDelayMs: 10000})
107
+ const initialConfig = normalizeConfig({
108
+ ...fixture.config,
109
+ processes: fixture.config.processes.map((processConfig) => processConfig.id === "worker"
110
+ ? {...processConfig, gracefulStopMs: "indefinite"}
111
+ : processConfig)
112
+ })
113
+ const configPath = await writeConfigFile(initialConfig, fixture.root)
114
+ const daemon = new RollbridgeDaemon({config: initialConfig, configPath, logger: () => {}})
115
+
116
+ try {
117
+ await daemon.start()
118
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
119
+
120
+ const refreshedConfig = normalizeConfig({
121
+ ...initialConfig,
122
+ processes: initialConfig.processes.map((processConfig) => processConfig.id === "worker"
123
+ ? {...processConfig, gracefulStopMs: 50}
124
+ : processConfig)
125
+ })
126
+
127
+ await writeConfigFile(refreshedConfig, fixture.root)
128
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
129
+ await waitFor(() => statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state === "stopped", 1000)
130
+
131
+ assert.equal(daemon.config.processes.find((processConfig) => processConfig.id === "worker")?.gracefulStopMs, 50)
132
+ assert.equal(await fetchText(daemon, "/release"), "v2")
133
+ } finally {
134
+ await daemon.shutdown()
135
+ await fs.rm(fixture.root, {force: true, recursive: true})
136
+ }
137
+ })
138
+
139
+ test("deploy rejects a reloaded config that changes the running proxy", async () => {
140
+ const fixture = await createFixture()
141
+ const configPath = await writeConfigFile(fixture.config, fixture.root)
142
+ const daemon = new RollbridgeDaemon({config: fixture.config, configPath, logger: () => {}})
143
+
144
+ try {
145
+ await daemon.start()
146
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
147
+ await writeConfigFile(normalizeConfig({
148
+ ...fixture.config,
149
+ proxy: {...fixture.config.proxy, host: "0.0.0.0"}
150
+ }), fixture.root)
151
+
152
+ await assert.rejects(
153
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
154
+ /proxy\.host.*restart the Rollbridge daemon/
155
+ )
156
+ assert.equal(daemon.status().activeReleaseId, "v1")
157
+ assert.equal(await fetchText(daemon, "/release"), "v1")
158
+ } finally {
159
+ await daemon.shutdown()
160
+ await fs.rm(fixture.root, {force: true, recursive: true})
161
+ }
162
+ })
163
+
164
+ test("a failed deploy does not adopt reloaded process config", async () => {
165
+ const fixture = await createFixture()
166
+ const configPath = await writeConfigFile(fixture.config, fixture.root)
167
+ const daemon = new RollbridgeDaemon({config: fixture.config, configPath, logger: () => {}})
168
+
169
+ try {
170
+ await daemon.start()
171
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
172
+
173
+ const failingConfig = normalizeConfig({
174
+ ...fixture.config,
175
+ processes: fixture.config.processes.map((processConfig) => processConfig.id === "web"
176
+ ? {...processConfig, health: {...processConfig.health, path: "/not-ready", timeoutMs: 100}}
177
+ : processConfig)
178
+ })
179
+
180
+ await writeConfigFile(failingConfig, fixture.root)
181
+ await assert.rejects(
182
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
183
+ /Health check failed/
184
+ )
185
+
186
+ assert.equal(daemon.config.processes.find((processConfig) => processConfig.id === "web")?.health?.path, "/ping")
187
+ assert.equal(daemon.status().activeReleaseId, "v1")
188
+ assert.equal(await fetchText(daemon, "/release"), "v1")
189
+ } finally {
190
+ await daemon.shutdown()
191
+ await fs.rm(fixture.root, {force: true, recursive: true})
192
+ }
193
+ })
194
+
105
195
  test("wildcard proxy bind host targets release processes through loopback", async () => {
106
196
  const fixture = await createFixture({proxyHost: "0.0.0.0"})
107
197
  const daemon = await startDaemon(fixture.config)
@@ -123,8 +213,8 @@ test("wildcard proxy bind host targets release processes through loopback", asyn
123
213
  }
124
214
  })
125
215
 
126
- test("failed release startup logs process output before cleanup", async () => {
127
- const fixture = await createFixture({webCommand: `${JSON.stringify(process.execPath)} -e "console.log('startup stdout'); console.error('startup stderr'); const http = require('node:http'); http.createServer((_request, response) => { response.writeHead(500); response.end('bad') }).listen(Number(process.env.ROLLBRIDGE_PORT), '127.0.0.1')"`, webHealthTimeoutMs: 500})
216
+ test("failed release startup logs process output and cleanup status", async () => {
217
+ const fixture = await createFixture({handoffService: true, webCommand: `${JSON.stringify(process.execPath)} -e "console.log('startup stdout'); console.error('startup stderr'); const http = require('node:http'); http.createServer((_request, response) => { response.writeHead(500); response.end('bad') }).listen(Number(process.env.ROLLBRIDGE_PORT), '127.0.0.1')"`, webHealthTimeoutMs: 500})
128
218
  /** @type {Array<{data?: Record<string, import("../src/json.js").JsonValue>, message: string}>} */
129
219
  const logs = []
130
220
  const daemon = new RollbridgeDaemon({
@@ -140,13 +230,22 @@ test("failed release startup logs process output before cleanup", async () => {
140
230
  /Health check failed/
141
231
  )
142
232
 
143
- const processStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.processId === "web")
233
+ const processStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.phase === "before cleanup" && entry.data?.processId === "web")
234
+ const cleanupProcessStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.phase === "after cleanup" && entry.data?.processId === "web")
235
+ const handoffServiceStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.phase === "after cleanup" && entry.data?.processId === "beacon")
144
236
 
145
237
  assert.ok(processStatusLog, "expected failed web process diagnostics to be logged")
146
238
  assert.ok(processStatusLog.data, "expected diagnostic data")
147
239
  assert.ok(Array.isArray(processStatusLog.data.logs), "expected retained process output in diagnostics")
148
240
  assert.ok(processStatusLog.data.logs.some((entry) => typeof entry === "object" && entry && "line" in entry && entry.line === "startup stdout"))
149
241
  assert.ok(processStatusLog.data.logs.some((entry) => typeof entry === "object" && entry && "line" in entry && entry.line === "startup stderr"))
242
+ assert.equal(processStatusLog.data.state, "running")
243
+ assert.ok(cleanupProcessStatusLog, "expected failed web cleanup diagnostics to be logged")
244
+ assert.equal(cleanupProcessStatusLog.data?.state, "stopped")
245
+ assert.equal(cleanupProcessStatusLog.data?.exitSignal, "SIGTERM")
246
+ assert.ok(handoffServiceStatusLog, "expected handoff service cleanup diagnostics to be logged")
247
+ assert.equal(handoffServiceStatusLog.data?.state, "stopped")
248
+ assert.equal(handoffServiceStatusLog.data?.exitSignal, "SIGTERM")
150
249
  } finally {
151
250
  await daemon.shutdown()
152
251
  await fs.rm(fixture.root, {force: true, recursive: true})
@@ -1267,10 +1366,9 @@ async function processEvents(logPath) {
1267
1366
  * @returns {Promise<string>} Written config path.
1268
1367
  */
1269
1368
  async function writeConfigFile(config, root) {
1270
- const configPath = path.join(root, "rollbridge.js")
1369
+ const configPath = path.join(root, "rollbridge.mjs")
1271
1370
 
1272
- // CommonJS so the module loads from a temp dir (no package.json) on any supported Node version.
1273
- await fs.writeFile(configPath, `module.exports = ${JSON.stringify(config, null, 2)}\n`)
1371
+ await fs.writeFile(configPath, `export default ${JSON.stringify(config, null, 2)}\n`)
1274
1372
 
1275
1373
  return configPath
1276
1374
  }