rollbridge 0.1.28 → 0.1.30

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.
Files changed (37) hide show
  1. package/AGENTS.md +14 -0
  2. package/README.md +69 -14
  3. package/TODO.md +5 -2
  4. package/changelog.d/20260828-atomic-owner-replacement.md +22 -0
  5. package/changelog.d/20260828-durable-owner-recovery.md +7 -0
  6. package/changelog.d/20260828-same-owner-jobs-generations.md +6 -0
  7. package/docs/cli.md +43 -21
  8. package/docs/config.md +71 -18
  9. package/docs/logging.md +8 -3
  10. package/docs/tensorbuzz-runbook.md +7 -6
  11. package/docs/troubleshooting.md +28 -12
  12. package/docs/velocious.md +11 -4
  13. package/docs/workers.md +8 -2
  14. package/examples/tensorbuzz.com.js +12 -4
  15. package/package.json +1 -1
  16. package/src/cli.js +209 -36
  17. package/src/config.js +8 -2
  18. package/src/control-client.js +118 -1
  19. package/src/daemon.js +939 -53
  20. package/src/guardian-client.js +434 -0
  21. package/src/managed-process.js +45 -15
  22. package/src/process-guardian.js +601 -0
  23. package/src/release-group.js +190 -15
  24. package/src/state-store.js +1 -1
  25. package/test/config-validation.test.js +22 -0
  26. package/test/fixtures/pre-split3-daemon-runner.js +30 -0
  27. package/test/fixtures/pre-split3-daemon.js +1336 -0
  28. package/test/fixtures/pre-split3-guardian-client.js +293 -0
  29. package/test/fixtures/pre-split3-process-guardian.js +292 -0
  30. package/test/fixtures/service-app.js +32 -2
  31. package/test/guardian-client.test.js +304 -0
  32. package/test/owner-recovery.test.js +950 -0
  33. package/test/owner-replacement.test.js +772 -0
  34. package/test/release-runtime-retention.test.js +1 -1
  35. package/test/rollbridge.test.js +178 -5
  36. package/test/shutdown-completion.test.js +1 -1
  37. package/test/state-store.test.js +12 -0
@@ -0,0 +1,950 @@
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 GuardianClient from "../src/guardian-client.js"
16
+
17
+ const currentDir = path.dirname(fileURLToPath(import.meta.url))
18
+ const binPath = path.join(currentDir, "..", "bin", "rollbridge")
19
+ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
20
+ const serviceAppPath = path.join(currentDir, "fixtures", "service-app.js")
21
+
22
+ /** @typedef {import("../src/daemon.js").DaemonStatus} DaemonStatus */
23
+ /** @typedef {DaemonStatus & {recovery: {configDigest: string}}} RecoveryState */
24
+
25
+ test("external owner retirement releases guardian authority without losing its generation", async () => {
26
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-recovery-"))
27
+ const socketPath = path.join(root, "rollbridge.sock")
28
+ const statePath = path.join(root, "rollbridge.state.json")
29
+ const v1Path = path.join(root, "v1")
30
+ const v2Path = path.join(root, "v2")
31
+ const config = normalizeConfig({
32
+ application: "owner-retirement-recovery-test",
33
+ control: {path: socketPath},
34
+ ownerRecovery: {reconnectGraceMs: 50},
35
+ processes: [
36
+ {
37
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
38
+ id: "worker",
39
+ lifecycle: {drainCommand: "printf started > \"$ROLLBRIDGE_RELEASE_PATH/drain-started\"; while [ ! -f \"$ROLLBRIDGE_RELEASE_PATH/drained\" ]; do sleep 0.01; done", drainTimeoutMs: 3000},
40
+ nonBlockingDrain: true,
41
+ policy: "companion"
42
+ },
43
+ {
44
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
45
+ health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
46
+ id: "web",
47
+ policy: "proxied",
48
+ port: {from: 0, to: 0}
49
+ }
50
+ ],
51
+ proxy: {forceStopTimeoutMs: 500, healthPath: "/ping", healthTimeoutMs: 3000, host: "127.0.0.1", port: 0},
52
+ statePath
53
+ })
54
+ const retired = new RollbridgeDaemon({config, logger: () => {}})
55
+ let replacement
56
+
57
+ try {
58
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
59
+ await retired.start()
60
+ await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
61
+ const before = retired.status()
62
+ const v1WorkerPid = releaseProcessPid(before, "v1", "worker")
63
+
64
+ replacement = new RollbridgeDaemon({config, logger: () => {}})
65
+ await replacement.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
66
+ const v2WorkerPid = releaseProcessPid(replacement.status(), "v2", "worker")
67
+
68
+ await retired.retireOwner({attestation: `sha256:${"a".repeat(64)}`})
69
+ await replacement.start({reportOrphans: false})
70
+ const recovered = replacement.status()
71
+ const v1 = recovered.releases.find(({releaseId}) => releaseId === "v1")
72
+ const v2 = recovered.releases.find(({releaseId}) => releaseId === "v2")
73
+
74
+ assert.equal(recovered.activeReleaseId, "v2", "the prestarted candidate must remain active")
75
+ assert.deepEqual(recovered.releaseReferences.sort((a, b) => a.releaseId.localeCompare(b.releaseId)), [
76
+ {releaseId: "v1", releasePath: v1Path},
77
+ {releaseId: "v2", releasePath: v2Path}
78
+ ])
79
+ assert.equal(v1?.state, "draining")
80
+ assert.equal(v1?.processes.find(({id}) => id === "worker")?.pid, v1WorkerPid)
81
+ assert.equal(v1?.processes.find(({id}) => id === "worker")?.state, "quiesced")
82
+ assert.equal(v2?.state, "active")
83
+ assert.equal(v2?.processes.find(({id}) => id === "worker")?.pid, v2WorkerPid)
84
+ assert.equal(v2?.processes.find(({id}) => id === "worker")?.state, "running")
85
+ await waitForFile(path.join(v1Path, "drain-started"), 1000)
86
+ await fs.writeFile(path.join(v1Path, "drained"), "done\n")
87
+ await waitForProcessExit(v1WorkerPid, 1000)
88
+ assert.equal(isAlive(v2WorkerPid), true, "the active candidate worker must remain usable while the old generation drains")
89
+ } finally {
90
+ await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "drained"), "done\n").catch(() => {})))
91
+ await replacement?.shutdown().catch(() => {})
92
+ retired.guardian?.disconnect()
93
+ await stopFixtureGuardian(statePath)
94
+ await fs.rm(root, {force: true, recursive: true})
95
+ }
96
+ })
97
+
98
+ test("replacement owner reconstructs one active and two draining generations after abrupt daemon exit", async () => {
99
+ const fixture = await createFixture()
100
+ let owner = spawnDaemon(fixture.configPath)
101
+ const managedProcessGroups = new Set()
102
+
103
+ try {
104
+ await waitForLog(owner, "control socket listening")
105
+ for (const releaseId of ["v1", "v2", "v3"]) {
106
+ const releasePath = await prepareRelease(fixture.root, releaseId, {holdJobsBind: releaseId === "v2"})
107
+
108
+ await sendControlCommand({command: {command: "deploy", releaseId, releasePath, revision: releaseId}, path: fixture.socketPath})
109
+ if (releaseId === "v2") await waitForFile(path.join(releasePath, "jobs.bind-waiting"))
110
+ const deployed = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
111
+
112
+ for (const retained of deployed.releases) {
113
+ for (const processStatus of retained.processes) if (processStatus.pid) managedProcessGroups.add(processStatus.pid)
114
+ }
115
+ for (const entry of [...deployed.services, ...deployed.singletons]) if (entry.process.pid) managedProcessGroups.add(entry.process.pid)
116
+ }
117
+
118
+ const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
119
+
120
+ assert.equal(before.activeReleaseId, "v3")
121
+ assert.deepEqual(before.releaseReferences.map((/** @type {{releaseId: string}} */ reference) => reference.releaseId), ["v1", "v2", "v3"])
122
+ const generationEndpoints = before.releases.map((release) => ({
123
+ jobsPort: release.ports.jobs,
124
+ jobsState: release.processes.find((processStatus) => processStatus.id === "jobs")?.state,
125
+ releaseId: release.releaseId,
126
+ state: release.state
127
+ }))
128
+
129
+ assert.equal(new Set(generationEndpoints.map(({jobsPort}) => jobsPort)).size, 3, JSON.stringify(generationEndpoints))
130
+ owner.kill("SIGKILL")
131
+ await once(owner, "exit")
132
+
133
+ owner = spawnDaemon(fixture.configPath)
134
+ await waitForLog(owner, "control socket listening")
135
+
136
+ const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
137
+
138
+ assert.equal(recovered.activeReleaseId, "v3")
139
+ assert.deepEqual(recovered.releases.map((/** @type {{state: string}} */ release) => release.state), ["draining", "draining", "active"])
140
+ assert.deepEqual(recovered.releaseReferences.map((/** @type {{releaseId: string}} */ reference) => reference.releaseId), ["v1", "v2", "v3"])
141
+ assert.deepEqual(recovered.releases.map((release) => release.ports.jobs), before.releases.map((release) => release.ports.jobs))
142
+ assert.equal(recovered.services[0]?.process.pid, before.services[0]?.process.pid)
143
+ assert.equal(recovered.singletons[0]?.process.pid, before.singletons[0]?.process.pid)
144
+
145
+ const v4Path = path.join(fixture.root, "v4")
146
+ await fs.mkdir(v4Path)
147
+ const v4Gate = spawn("mkfifo", [path.join(v4Path, "worker.fifo")])
148
+ assert.equal((await once(v4Gate, "exit"))[0], 0)
149
+ await sendControlCommand({command: {command: "deploy", releaseId: "v4", releasePath: v4Path, revision: "v4"}, path: fixture.socketPath})
150
+ assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v4", "new work must progress while old generations remain retained")
151
+
152
+ await Promise.all(["v1", "v2"].map((releaseId) => fs.writeFile(path.join(fixture.root, releaseId, "worker.fifo"), "drained\n")))
153
+ const afterDrain = await waitForState(fixture.statePath, (state) => state.releaseReferences?.map((/** @type {{releaseId: string}} */ reference) => reference.releaseId).join(",") === "v3,v4")
154
+ assert.deepEqual(afterDrain.releaseReferences.map((/** @type {{releaseId: string}} */ reference) => reference.releaseId), ["v3", "v4"])
155
+
156
+ await fs.writeFile(path.join(fixture.root, "v3", "worker.fifo"), "drained\n")
157
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
158
+ await fs.writeFile(path.join(fixture.root, "v4", "worker.fifo"), "drained\n")
159
+ await shutdown
160
+ await once(owner, "exit")
161
+ } finally {
162
+ await killChild(owner)
163
+ for (const pid of managedProcessGroups) {
164
+ try {
165
+ process.kill(-pid, "SIGKILL")
166
+ } catch (_error) {
167
+ // The exact managed process group may already have completed.
168
+ }
169
+ }
170
+ await stopFixtureGuardian(fixture.statePath)
171
+ await fs.rm(fixture.root, {force: true, recursive: true})
172
+ }
173
+ })
174
+
175
+ test("owner recovery rejects config identity mismatch without changing the valid snapshot", async () => {
176
+ const fixture = await createFixture()
177
+ let owner = spawnDaemon(fixture.configPath)
178
+ let workerPid
179
+
180
+ try {
181
+ await waitForLog(owner, "control socket listening")
182
+ const releasePath = await prepareRelease(fixture.root, "v1")
183
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath})
184
+ const status = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
185
+ workerPid = status.releases[0]?.processes.find((processStatus) => processStatus.id === "worker")?.pid
186
+ assert.ok(workerPid)
187
+ const validState = await fs.readFile(fixture.statePath, "utf8")
188
+
189
+ owner.kill("SIGKILL")
190
+ await once(owner, "exit")
191
+ await writeConfig(fixture.configPath, {...fixture.config, application: "different-authority"})
192
+
193
+ const rejected = await runDaemon(fixture.configPath)
194
+
195
+ assert.notEqual(rejected.code, 0)
196
+ assert.match(rejected.output, /config identity does not match/)
197
+ assert.equal(await fs.readFile(fixture.statePath, "utf8"), validState)
198
+
199
+ await writeConfig(fixture.configPath, fixture.config)
200
+ owner = spawnDaemon(fixture.configPath)
201
+ await waitForLog(owner, "control socket listening")
202
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
203
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
204
+ await shutdown
205
+ await once(owner, "exit")
206
+ } finally {
207
+ await killChild(owner)
208
+ if (workerPid) {
209
+ try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact group already exited. */ }
210
+ }
211
+ await stopFixtureGuardian(fixture.statePath)
212
+ await fs.rm(fixture.root, {force: true, recursive: true})
213
+ }
214
+ })
215
+
216
+ test("failed recovery bootstrap keeps the reconstructed active generation serving", async () => {
217
+ const fixture = await createFixture()
218
+ let owner = spawnDaemon(fixture.configPath)
219
+ let workerPid
220
+
221
+ try {
222
+ await waitForLog(owner, "control socket listening")
223
+ const releasePath = await prepareRelease(fixture.root, "v1")
224
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath})
225
+ const status = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
226
+ workerPid = status.releases[0]?.processes.find((processStatus) => processStatus.id === "worker")?.pid
227
+ assert.ok(workerPid)
228
+
229
+ owner.kill("SIGKILL")
230
+ await once(owner, "exit")
231
+ owner = spawnDaemon(fixture.configPath, {releaseId: "bad", releasePath: fixture.root, revision: "bad"})
232
+ await waitForLog(owner, "control socket listening")
233
+
234
+ const preserved = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
235
+ const events = await sendControlCommand({command: {command: "events"}, path: fixture.socketPath})
236
+
237
+ assert.equal(preserved.activeReleaseId, "v1")
238
+ assert.deepEqual(preserved.releaseReferences, [{releaseId: "v1", releasePath}])
239
+ assert.ok(Array.isArray(events.events) && events.events.some((event) => event && typeof event === "object" && "message" in event && event.message === "bootstrap activation failed"))
240
+
241
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
242
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
243
+ await shutdown
244
+ await once(owner, "exit")
245
+ } finally {
246
+ await killChild(owner)
247
+ if (workerPid) {
248
+ try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact group already exited. */ }
249
+ }
250
+ await stopFixtureGuardian(fixture.statePath)
251
+ await fs.rm(fixture.root, {force: true, recursive: true})
252
+ }
253
+ })
254
+
255
+ test("owner recovery fails closed on a partial snapshot and preserves it for repair", async () => {
256
+ const fixture = await createFixture()
257
+ let owner = spawnDaemon(fixture.configPath)
258
+ let workerPid
259
+
260
+ try {
261
+ await waitForLog(owner, "control socket listening")
262
+ const releasePath = await prepareRelease(fixture.root, "v1")
263
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath})
264
+ const status = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
265
+ workerPid = status.releases[0]?.processes.find((processStatus) => processStatus.id === "worker")?.pid
266
+ assert.ok(workerPid)
267
+ const validState = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
268
+
269
+ owner.kill("SIGKILL")
270
+ await once(owner, "exit")
271
+ const partialState = {...validState, releases: []}
272
+ await fs.writeFile(fixture.statePath, `${JSON.stringify(partialState, null, 2)}\n`)
273
+
274
+ const rejected = await runDaemon(fixture.configPath)
275
+
276
+ assert.notEqual(rejected.code, 0)
277
+ assert.match(rejected.output, /does not contain active release v1/)
278
+ assert.deepEqual(JSON.parse(await fs.readFile(fixture.statePath, "utf8")), partialState)
279
+
280
+ await fs.writeFile(fixture.statePath, `${JSON.stringify(validState, null, 2)}\n`)
281
+ owner = spawnDaemon(fixture.configPath)
282
+ await waitForLog(owner, "control socket listening")
283
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
284
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
285
+ await shutdown
286
+ await once(owner, "exit")
287
+ } finally {
288
+ await killChild(owner)
289
+ if (workerPid) {
290
+ try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact group already exited. */ }
291
+ }
292
+ await stopFixtureGuardian(fixture.statePath)
293
+ await fs.rm(fixture.root, {force: true, recursive: true})
294
+ }
295
+ })
296
+
297
+ test("concurrent same-authority replacements converge on one fenced owner", async () => {
298
+ const fixture = await createFixture()
299
+ let owner = spawnDaemon(fixture.configPath)
300
+ /** @type {import("node:child_process").ChildProcess | undefined} */
301
+ let contender
302
+ let workerPid
303
+
304
+ try {
305
+ await waitForLog(owner, "control socket listening")
306
+ const releasePath = await prepareRelease(fixture.root, "v1")
307
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath})
308
+ const status = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
309
+ workerPid = status.releases[0]?.processes.find((processStatus) => processStatus.id === "worker")?.pid
310
+ assert.ok(workerPid)
311
+
312
+ owner.kill("SIGKILL")
313
+ await once(owner, "exit")
314
+ owner = spawnDaemon(fixture.configPath)
315
+ const secondContender = spawnDaemon(fixture.configPath)
316
+ contender = secondContender
317
+
318
+ const winner = await Promise.any([
319
+ waitForLog(owner, "control socket listening").then(() => owner),
320
+ waitForLog(secondContender, "control socket listening").then(() => secondContender)
321
+ ])
322
+ const loser = winner === owner ? secondContender : owner
323
+ const [loserCode] = loser.exitCode === null && loser.signalCode === null ? await once(loser, "exit") : [loser.exitCode]
324
+
325
+ assert.equal(loserCode, 0, "fenced loser must attest the matching winner and exit successfully")
326
+ assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v1")
327
+
328
+ owner = winner
329
+ contender = undefined
330
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
331
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
332
+ await shutdown
333
+ await once(owner, "exit")
334
+ } finally {
335
+ await killChild(owner)
336
+ await killChild(contender)
337
+ if (workerPid) {
338
+ try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact group already exited. */ }
339
+ }
340
+ await stopFixtureGuardian(fixture.statePath)
341
+ await fs.rm(fixture.root, {force: true, recursive: true})
342
+ }
343
+ })
344
+
345
+ test("fresh guardian identity remains recoverable when proxy startup fails", async () => {
346
+ const fixture = await createFixture()
347
+ const blocker = net.createServer()
348
+ const config = /** @type {import("../src/config.js").RollbridgeConfig} */ (fixture.config)
349
+ /** @type {RollbridgeDaemon | undefined} */
350
+ let failedOwner
351
+ /** @type {RollbridgeDaemon | undefined} */
352
+ let replacement
353
+
354
+ try {
355
+ await new Promise((resolve, reject) => blocker.listen(0, "127.0.0.1", () => resolve(undefined)).once("error", reject))
356
+ const address = blocker.address()
357
+ assert.ok(address && typeof address === "object")
358
+ config.proxy.port = address.port
359
+ const startupAttempt = new RollbridgeDaemon({config, logger: () => {}})
360
+ failedOwner = startupAttempt
361
+ await assert.rejects(() => startupAttempt.start(), /EADDRINUSE/)
362
+
363
+ const state = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
364
+ assert.equal(typeof state.recovery?.guardian?.token, "string")
365
+ failedOwner.abandonOwnerRecoveryAttempt()
366
+ await new Promise((resolve) => blocker.close(() => resolve(undefined)))
367
+
368
+ replacement = new RollbridgeDaemon({config, logger: () => {}})
369
+ await replacement.start()
370
+ assert.equal(replacement.status().activeReleaseId, null)
371
+ await replacement.shutdown()
372
+ } finally {
373
+ if (blocker.listening) await new Promise((resolve) => blocker.close(() => resolve(undefined)))
374
+ if (!replacement && failedOwner?.guardian) await failedOwner.guardian.shutdown()
375
+ else failedOwner?.guardian?.disconnect()
376
+ replacement?.guardian?.disconnect()
377
+ await stopFixtureGuardian(fixture.statePath)
378
+ await fs.rm(fixture.root, {force: true, recursive: true})
379
+ }
380
+ })
381
+
382
+ test("replacement reconstructs retained draining generations without an active release", async () => {
383
+ const fixture = await createFixture()
384
+ const config = /** @type {import("../src/config.js").RollbridgeConfig} */ (fixture.config)
385
+ config.processes = config.processes.filter((processConfig) => processConfig.id !== "beacon" && processConfig.id !== "singleton")
386
+ await writeConfig(fixture.configPath, config)
387
+ let owner = spawnDaemon(fixture.configPath)
388
+
389
+ try {
390
+ await waitForLog(owner, "control socket listening")
391
+ const v1Path = await prepareRelease(fixture.root, "v1")
392
+ const v2Path = await prepareRelease(fixture.root, "v2")
393
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
394
+ await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
395
+
396
+ const stopActive = sendControlCommand({command: {command: "stop", releaseId: "v2"}, path: fixture.socketPath})
397
+ await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
398
+ await stopActive
399
+ const drainOnly = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
400
+ assert.equal(drainOnly.activeReleaseId, null)
401
+ assert.deepEqual(drainOnly.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
402
+ await waitForState(fixture.statePath, (state) => state.activeReleaseId === null && state.releaseReferences?.length === 1 && state.releaseReferences[0]?.releaseId === "v1")
403
+
404
+ owner.kill("SIGKILL")
405
+ await once(owner, "exit")
406
+ owner = spawnDaemon(fixture.configPath)
407
+ await waitForLog(owner, "control socket listening")
408
+ const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
409
+ assert.equal(recovered.activeReleaseId, null)
410
+ assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
411
+
412
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
413
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
414
+ await assert.doesNotReject(() => shutdown, "replacement shutdown must acknowledge after the recovered drain settles")
415
+ await once(owner, "exit")
416
+ } finally {
417
+ await killChild(owner)
418
+ await stopFixtureGuardian(fixture.statePath)
419
+ await fs.rm(fixture.root, {force: true, recursive: true})
420
+ }
421
+ })
422
+
423
+ test("deploy rejects a live ownerRecovery mode change", async () => {
424
+ const fixture = await createFixture()
425
+ const owner = spawnDaemon(fixture.configPath)
426
+ const releasePath = await prepareRelease(fixture.root, "v1")
427
+
428
+ try {
429
+ await waitForLog(owner, "control socket listening")
430
+ const changedConfig = {...fixture.config}
431
+ delete changedConfig.ownerRecovery
432
+ await writeConfig(fixture.configPath, changedConfig)
433
+
434
+ await assert.rejects(
435
+ sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath}),
436
+ /ownerRecovery.*cannot be applied live/
437
+ )
438
+
439
+ await writeConfig(fixture.configPath, fixture.config)
440
+ await sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
441
+ await once(owner, "exit")
442
+ } finally {
443
+ await killChild(owner)
444
+ await stopFixtureGuardian(fixture.statePath)
445
+ await fs.rm(fixture.root, {force: true, recursive: true})
446
+ }
447
+ })
448
+
449
+ test("replacement removes only guardian-owned candidate inventory left before deploy commit", async () => {
450
+ const fixture = await createFixture()
451
+ let owner = spawnDaemon(fixture.configPath)
452
+ const v1Path = await prepareRelease(fixture.root, "v1")
453
+ const v2Path = await prepareRelease(fixture.root, "v2")
454
+ let candidatePid
455
+
456
+ try {
457
+ await waitForLog(owner, "control socket listening")
458
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
459
+ const committedState = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
460
+ const candidateConfig = /** @type {import("../src/config.js").RollbridgeConfig} */ (structuredClone(fixture.config))
461
+ const worker = candidateConfig.processes.find((processConfig) => processConfig.id === "worker")
462
+ const web = candidateConfig.processes.find((processConfig) => processConfig.id === "web")
463
+
464
+ assert.ok(worker && web)
465
+ worker.command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify("const fs = require('node:fs'); const target = process.env.ROLLBRIDGE_RELEASE_PATH + '/candidate.pid'; fs.writeFileSync(target + '.tmp', String(process.pid)); fs.renameSync(target + '.tmp', target); setInterval(() => {}, 1000)")}`
466
+ worker.lifecycle = {drainTimeoutMs: 0}
467
+ web.command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`
468
+ await writeConfig(fixture.configPath, candidateConfig)
469
+ const interruptedDeploy = sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
470
+ void interruptedDeploy.catch(() => {})
471
+ const candidatePidPath = path.join(v2Path, "candidate.pid")
472
+
473
+ await waitForFile(candidatePidPath)
474
+ candidatePid = Number(await fs.readFile(candidatePidPath, "utf8"))
475
+ assert.ok(Number.isInteger(candidatePid) && candidatePid > 0)
476
+ owner.kill("SIGKILL")
477
+ await once(owner, "exit")
478
+ const stateAfterDeath = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
479
+
480
+ assert.equal(stateAfterDeath.activeReleaseId, committedState.activeReleaseId, "owner death must preserve the last committed active release")
481
+ assert.deepEqual(stateAfterDeath.releaseReferences, committedState.releaseReferences, "owner death must preserve committed release references")
482
+ assert.deepEqual(stateAfterDeath.releases, committedState.releases, "owner death must not commit candidate release metadata")
483
+
484
+ await writeConfig(fixture.configPath, fixture.config)
485
+ owner = spawnDaemon(fixture.configPath)
486
+ await waitForLog(owner, "control socket listening")
487
+ const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
488
+
489
+ assert.equal(recovered.activeReleaseId, "v1")
490
+ assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
491
+ assert.equal(isAlive(candidatePid), false, "uncommitted candidate must be stopped before replacement becomes healthy")
492
+
493
+ await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
494
+ assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2", "removed candidate keys must be reusable by a later valid deploy")
495
+
496
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
497
+ await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
498
+ await shutdown
499
+ await once(owner, "exit")
500
+ } finally {
501
+ await killChild(owner)
502
+ if (candidatePid && isAlive(candidatePid)) {
503
+ try { process.kill(-candidatePid, "SIGKILL") } catch (_error) { /* Exact candidate group already exited. */ }
504
+ }
505
+ await stopFixtureGuardian(fixture.statePath)
506
+ await fs.rm(fixture.root, {force: true, recursive: true})
507
+ }
508
+ })
509
+
510
+ test("ensure-daemon atomically replaces an incompatible owner without losing retained generations", async () => {
511
+ const fixture = await createFixture()
512
+ const oldControlPath = fixture.socketPath
513
+ const newControlPath = path.join(fixture.root, "rollbridge-v2.sock")
514
+ const runtimePath = path.join(fixture.root, "runtime")
515
+ const daemonLogPath = path.join(fixture.root, "replacement.log")
516
+ const daemonPidPath = path.join(fixture.root, "replacement.pid")
517
+ let owner = spawnDaemon(fixture.configPath)
518
+ const processGroups = new Set()
519
+ let cleanupControlPath = oldControlPath
520
+ let retainedConnection
521
+ let retainedConnectionClose
522
+
523
+ try {
524
+ await waitForLog(owner, "control socket listening")
525
+ const v1Path = await prepareRelease(fixture.root, "v1")
526
+ const v2Path = await prepareRelease(fixture.root, "v2")
527
+
528
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldControlPath})
529
+ const activeV1 = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: oldControlPath}))
530
+ retainedConnection = await openWebSocket(/** @type {{port: number}} */ (activeV1.proxy).port)
531
+ retainedConnectionClose = once(retainedConnection, "close")
532
+ let retainedConnectionClosed = false
533
+
534
+ retainedConnection.once("close", () => { retainedConnectionClosed = true })
535
+ await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldControlPath})
536
+ const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: oldControlPath}))
537
+
538
+ for (const release of before.releases) for (const processStatus of release.processes) if (processStatus.pid) processGroups.add(processStatus.pid)
539
+ for (const entry of [...before.services, ...before.singletons]) if (entry.process.pid) processGroups.add(entry.process.pid)
540
+
541
+ const nextConfig = /** @type {import("../src/config.js").RollbridgeConfig} */ (structuredClone(fixture.config))
542
+ nextConfig.control = {path: newControlPath}
543
+ const companionTemplate = nextConfig.processes.find((processConfig) => processConfig.policy === "companion")
544
+
545
+ assert.ok(companionTemplate)
546
+ nextConfig.processes.splice(2, 0, {
547
+ ...structuredClone(companionTemplate),
548
+ id: "new-topology-process",
549
+ lifecycle: {drainTimeoutMs: 0},
550
+ nonBlockingDrain: false
551
+ })
552
+ await writeConfig(fixture.configPath, nextConfig)
553
+
554
+ const replacement = await runCli([
555
+ "ensure-daemon", "--config", fixture.configPath,
556
+ "--daemon-log-path", daemonLogPath,
557
+ "--daemon-pid-path", daemonPidPath,
558
+ "--daemon-runtime-path", runtimePath,
559
+ "--daemon-start-timeout-ms", "5000"
560
+ ])
561
+ assert.equal(replacement.code, 0, `${replacement.output}\n${await fs.readFile(daemonLogPath, "utf8")}`)
562
+ cleanupControlPath = newControlPath
563
+
564
+ const after = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: newControlPath}))
565
+
566
+ assert.equal(after.activeReleaseId, "v2")
567
+ assert.deepEqual(after.releaseReferences, before.releaseReferences)
568
+ assert.deepEqual(after.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)), before.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)))
569
+ assert.equal(after.services[0]?.process.pid, before.services[0]?.process.pid)
570
+ assert.equal(after.singletons[0]?.process.pid, before.singletons[0]?.process.pid)
571
+ assert.equal(retainedConnectionClosed, false, "listener-owned WebSocket must remain supervised across replacement")
572
+
573
+ const v3Path = await prepareRelease(fixture.root, "v3")
574
+ await sendControlCommand({command: {command: "deploy", releaseId: "v3", releasePath: v3Path, revision: "v3"}, path: newControlPath})
575
+ const deployed = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: newControlPath}))
576
+
577
+ assert.equal(deployed.activeReleaseId, "v3")
578
+ assert.deepEqual(deployed.releaseReferences.map((reference) => reference.releaseId), ["v1", "v2", "v3"])
579
+ assert.equal(releaseProcessPid(deployed, "v1", "web"), releaseProcessPid(before, "v1", "web"))
580
+ assert.equal(retainedConnectionClosed, false, "a later deploy must not stop a process with a transferred live connection")
581
+
582
+ retainedConnection.destroy()
583
+ await retainedConnectionClose
584
+ await Promise.all(["v1", "v2"].map((releaseId) => fs.writeFile(path.join(fixture.root, releaseId, "worker.fifo"), "drained\n")))
585
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newControlPath})
586
+ await fs.writeFile(path.join(v3Path, "worker.fifo"), "drained\n")
587
+ await shutdown
588
+ } finally {
589
+ retainedConnection?.destroy()
590
+ await retainedConnectionClose?.catch(() => undefined)
591
+ try {
592
+ const status = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: cleanupControlPath}))
593
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: cleanupControlPath})
594
+
595
+ await Promise.all([
596
+ ...status.releaseReferences.map(({releaseId}) => fs.writeFile(path.join(fixture.root, releaseId, "worker.fifo"), "drained\n").catch(() => undefined)),
597
+ shutdown
598
+ ])
599
+ } catch (_error) {
600
+ // Exact process and guardian cleanup below handles a daemon that failed before control publication.
601
+ }
602
+ await killChild(owner)
603
+ for (const pid of processGroups) {
604
+ try { process.kill(-pid, "SIGKILL") } catch (_error) { /* Exact managed group already exited. */ }
605
+ }
606
+ await stopFixtureGuardian(fixture.statePath)
607
+ await fs.rm(fixture.root, {force: true, recursive: true})
608
+ }
609
+ })
610
+
611
+ /** @returns {Promise<{config: Record<string, import("../src/json.js").JsonValue>, configPath: string, root: string, socketPath: string, statePath: string}>} Fixture paths. */
612
+ async function createFixture() {
613
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-recovery-"))
614
+ const socketPath = path.join(root, "rollbridge.sock")
615
+ const configPath = path.join(root, "rollbridge.cjs")
616
+ const statePath = path.join(root, "rollbridge.state.json")
617
+ const serviceLogPath = path.join(root, "service.log")
618
+ const config = {
619
+ application: "owner-recovery-test",
620
+ control: {path: socketPath},
621
+ ownerRecovery: {reconnectGraceMs: 250},
622
+ processes: [
623
+ {
624
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(serviceAppPath)}`,
625
+ env: {ROLLBRIDGE_SERVICE_LOG: serviceLogPath},
626
+ id: "beacon",
627
+ policy: "service",
628
+ port: {from: 17100, to: 17120}
629
+ },
630
+ {
631
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(serviceAppPath)}`,
632
+ deployStrategy: "handoff",
633
+ env: {
634
+ ROLLBRIDGE_SERVICE_BIND_GATE: "{{releasePath}}/jobs.bind",
635
+ ROLLBRIDGE_SERVICE_BIND_WAITING: "{{releasePath}}/jobs.bind-waiting",
636
+ ROLLBRIDGE_SERVICE_LOG: serviceLogPath
637
+ },
638
+ id: "jobs",
639
+ lifecycle: {quietCommand: "exit 0"},
640
+ policy: "service",
641
+ port: {from: 17000, to: 17020}
642
+ },
643
+ {
644
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
645
+ id: "worker",
646
+ lifecycle: {drainCommand: "read released < \"$ROLLBRIDGE_RELEASE_PATH/worker.fifo\"", drainTimeoutMs: 60000},
647
+ nonBlockingDrain: true,
648
+ policy: "companion"
649
+ },
650
+ {
651
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
652
+ health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
653
+ id: "web",
654
+ policy: "proxied",
655
+ port: {from: 0, to: 0}
656
+ },
657
+ {
658
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
659
+ id: "singleton",
660
+ policy: "singleton"
661
+ }
662
+ ],
663
+ proxy: {drainTimeoutMs: 1000, forceStopTimeoutMs: 500, healthPath: "/ping", healthTimeoutMs: 3000, host: "127.0.0.1", port: 0},
664
+ statePath
665
+ }
666
+
667
+ await writeConfig(configPath, config)
668
+
669
+ return {config, configPath, root, socketPath, statePath}
670
+ }
671
+
672
+ /**
673
+ * @param {string} root - Fixture root.
674
+ * @param {string} releaseId - Release id.
675
+ * @param {{holdJobsBind?: boolean}} [options] - Whether the handoff service must remain unbound.
676
+ * @returns {Promise<string>} Prepared release path.
677
+ */
678
+ async function prepareRelease(root, releaseId, {holdJobsBind = false} = {}) {
679
+ const releasePath = path.join(root, releaseId)
680
+
681
+ await fs.mkdir(releasePath)
682
+ if (!holdJobsBind) await fs.writeFile(path.join(releasePath, "jobs.bind"), "ready\n")
683
+ const gate = spawn("mkfifo", [path.join(releasePath, "worker.fifo")])
684
+ assert.equal((await once(gate, "exit"))[0], 0)
685
+ return releasePath
686
+ }
687
+
688
+ /**
689
+ * @param {string} configPath - Config path.
690
+ * @param {Record<string, import("../src/json.js").JsonValue>} config - Raw config.
691
+ * @returns {Promise<void>} Write completion.
692
+ */
693
+ async function writeConfig(configPath, config) {
694
+ await fs.writeFile(configPath, `module.exports = ${JSON.stringify(config, null, 2)}\n`)
695
+ }
696
+
697
+ /**
698
+ * @param {string} statePath - Fixture state path.
699
+ * @returns {Promise<void>} Cleanup completion.
700
+ */
701
+ async function stopFixtureGuardian(statePath) {
702
+ try {
703
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"))
704
+ const identity = state.recovery?.guardian
705
+
706
+ if (!identity || typeof identity.pid !== "number" || typeof identity.socketPath !== "string" || typeof identity.token !== "string") return
707
+ const command = (await fs.readFile(`/proc/${identity.pid}/cmdline`)).toString().replaceAll("\0", " ")
708
+
709
+ if (!command.includes("process-guardian.js") || !command.includes(statePath)) throw new Error(`Refusing to stop unverified fixture guardian pid ${identity.pid}`)
710
+ const client = new GuardianClient(identity)
711
+
712
+ await client.connect()
713
+ const inventory = await client.inventory()
714
+
715
+ try {
716
+ await client.shutdown()
717
+ return
718
+ } catch (error) {
719
+ if (!(error instanceof Error) || !/requires the committed owner/.test(error.message)) throw error
720
+ client.disconnect()
721
+ try { process.kill(-identity.pid, "SIGKILL") } catch (killError) {
722
+ if (!killError || typeof killError !== "object" || !("code" in killError) || killError.code !== "ESRCH") throw killError
723
+ }
724
+ }
725
+ for (const entry of inventory) {
726
+ if (!entry.status.pid) continue
727
+ try { process.kill(-entry.status.pid, "SIGKILL") } catch (error) {
728
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
729
+ }
730
+ }
731
+ } catch (error) {
732
+ if (error && typeof error === "object" && "code" in error && (error.code === "ENOENT" || error.code === "ESRCH")) return
733
+ throw error
734
+ }
735
+ }
736
+
737
+ /**
738
+ * @param {string} statePath - State path.
739
+ * @param {(state: RecoveryState) => boolean} predicate - Completion predicate.
740
+ * @returns {Promise<RecoveryState>} Matching state.
741
+ */
742
+ async function waitForState(statePath, predicate) {
743
+ const watcher = fs.watch(path.dirname(statePath))
744
+
745
+ try {
746
+ const initial = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(statePath, "utf8")))
747
+ if (predicate(initial)) return initial
748
+
749
+ for await (const change of watcher) {
750
+ if (change.filename !== path.basename(statePath)) continue
751
+ const state = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(statePath, "utf8")))
752
+
753
+ if (predicate(state)) return state
754
+ }
755
+ } finally {
756
+ await watcher.return?.()
757
+ }
758
+
759
+ throw new Error("State watcher ended before the expected snapshot")
760
+ }
761
+
762
+ /**
763
+ * @param {string} filePath - File whose creation is the transaction-boundary signal.
764
+ * @param {number} [timeoutMs] - Optional bounded wait.
765
+ * @returns {Promise<void>} Resolves when the file exists.
766
+ */
767
+ async function waitForFile(filePath, timeoutMs) {
768
+ try {
769
+ await fs.access(filePath)
770
+ return
771
+ } catch (error) {
772
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
773
+ }
774
+ const controller = timeoutMs === undefined ? undefined : new AbortController()
775
+ const timer = timeoutMs === undefined ? undefined : setTimeout(() => controller?.abort(), timeoutMs)
776
+ const watcher = fs.watch(path.dirname(filePath), {signal: controller?.signal})
777
+
778
+ try {
779
+ for await (const change of watcher) {
780
+ if (change.filename !== path.basename(filePath)) continue
781
+ try {
782
+ await fs.access(filePath)
783
+ return
784
+ } catch (error) {
785
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
786
+ }
787
+ }
788
+ } catch (error) {
789
+ if (error && typeof error === "object" && "name" in error && error.name === "AbortError") throw new Error(`Timed out waiting for ${filePath}`, {cause: error})
790
+ throw error
791
+ } finally {
792
+ clearTimeout(timer)
793
+ await watcher.return?.()
794
+ }
795
+ }
796
+
797
+ /**
798
+ * @param {number} pid - Exact fixture process.
799
+ * @param {number} timeoutMs - Bounded exit wait.
800
+ */
801
+ async function waitForProcessExit(pid, timeoutMs) {
802
+ const deadline = Date.now() + timeoutMs
803
+
804
+ while (isAlive(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10))
805
+ assert.equal(isAlive(pid), false, `process ${pid} did not exit within ${timeoutMs}ms`)
806
+ }
807
+
808
+ /**
809
+ * Opens a live WebSocket through the fixture proxy.
810
+ * @param {number} port - Proxy port.
811
+ * @returns {Promise<net.Socket>} Upgraded socket.
812
+ */
813
+ async function openWebSocket(port) {
814
+ const socket = net.createConnection({host: "127.0.0.1", port})
815
+
816
+ await once(socket, "connect")
817
+ socket.write([
818
+ "GET /socket HTTP/1.1",
819
+ "Host: 127.0.0.1",
820
+ "Connection: Upgrade",
821
+ "Upgrade: websocket",
822
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
823
+ "Sec-WebSocket-Version: 13",
824
+ "\r\n"
825
+ ].join("\r\n"))
826
+ const [response] = await once(socket, "data")
827
+
828
+ assert.match(String(response), /^HTTP\/1\.1 101 /)
829
+ return socket
830
+ }
831
+
832
+ /**
833
+ * @param {DaemonStatus} status - Daemon status.
834
+ * @param {string} releaseId - Release id.
835
+ * @param {string} processId - Process id.
836
+ * @returns {number} Managed process PID.
837
+ */
838
+ function releaseProcessPid(status, releaseId, processId) {
839
+ const pid = status.releases.find((release) => release.releaseId === releaseId)?.processes.find((entry) => entry.id === processId)?.pid
840
+
841
+ if (typeof pid !== "number") throw new Error(`Missing ${processId} PID for release ${releaseId}`)
842
+ return pid
843
+ }
844
+
845
+ /**
846
+ * @param {number} pid - Exact fixture pid.
847
+ * @returns {boolean} Whether the exact fixture process is alive.
848
+ */
849
+ function isAlive(pid) {
850
+ try {
851
+ process.kill(pid, 0)
852
+ return true
853
+ } catch (error) {
854
+ if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false
855
+ throw error
856
+ }
857
+ }
858
+
859
+ /**
860
+ * @param {string} configPath - Config path.
861
+ * @param {{releaseId: string, releasePath: string, revision: string}} [bootstrap] - Optional bootstrap tuple.
862
+ * @returns {import("node:child_process").ChildProcess} Daemon process.
863
+ */
864
+ function spawnDaemon(configPath, bootstrap) {
865
+ const args = [binPath, "daemon", "--config", configPath]
866
+
867
+ if (bootstrap) args.push("--release-id", bootstrap.releaseId, "--release-path", bootstrap.releasePath, "--revision", bootstrap.revision)
868
+ return spawn(process.execPath, args, {stdio: ["ignore", "pipe", "pipe"]})
869
+ }
870
+
871
+ /**
872
+ * Terminates one exact daemon fixture and awaits its exit before guardian cleanup.
873
+ * @param {import("node:child_process").ChildProcess | undefined} child - Exact fixture child.
874
+ */
875
+ async function killChild(child) {
876
+ if (!child || child.exitCode !== null || child.signalCode !== null) return
877
+ const exited = once(child, "exit")
878
+
879
+ child.kill("SIGKILL")
880
+ await exited
881
+ }
882
+
883
+ /**
884
+ * @param {string} configPath - Config path.
885
+ * @returns {Promise<{code: number | null, output: string}>} Exit result.
886
+ */
887
+ async function runDaemon(configPath) {
888
+ const child = spawnDaemon(configPath)
889
+ let output = ""
890
+
891
+ child.stdout?.setEncoding("utf8").on("data", (chunk) => { output += chunk })
892
+ child.stderr?.setEncoding("utf8").on("data", (chunk) => { output += chunk })
893
+ const [code] = await once(child, "exit")
894
+ return {code, output}
895
+ }
896
+
897
+ /**
898
+ * @param {string[]} args - CLI arguments.
899
+ * @returns {Promise<{code: number, output: string}>} Exit result.
900
+ */
901
+ async function runCli(args) {
902
+ const child = spawn(process.execPath, [binPath, ...args], {stdio: ["ignore", "pipe", "pipe"]})
903
+ let output = ""
904
+
905
+ child.stdout?.setEncoding("utf8").on("data", (chunk) => { output += chunk })
906
+ child.stderr?.setEncoding("utf8").on("data", (chunk) => { output += chunk })
907
+ const [code] = await once(child, "exit")
908
+ return {code, output}
909
+ }
910
+
911
+ /**
912
+ * @param {import("node:child_process").ChildProcess} child - Daemon child.
913
+ * @param {string} message - Structured log message.
914
+ */
915
+ async function waitForLog(child, message) {
916
+ assert.ok(child.stdout)
917
+ child.stdout.setEncoding("utf8")
918
+
919
+ await new Promise((resolve, reject) => {
920
+ let buffer = ""
921
+ let stderr = ""
922
+ const onErrorData = (/** @type {string} */ chunk) => { stderr += chunk }
923
+ const onExit = () => finish(new Error(`Daemon exited before logging ${message}: ${stderr.trim()}`))
924
+ /** @param {string} chunk - Output chunk. */
925
+ const onData = (chunk) => {
926
+ buffer += chunk
927
+ const lines = buffer.split("\n")
928
+
929
+ buffer = lines.pop() || ""
930
+ for (const line of lines) {
931
+ if (line && JSON.parse(line).message === message) {
932
+ finish()
933
+ return
934
+ }
935
+ }
936
+ }
937
+ /** @param {Error} [error] - Failure. */
938
+ const finish = (error) => {
939
+ child.off("exit", onExit)
940
+ child.stdout?.off("data", onData)
941
+ child.stderr?.off("data", onErrorData)
942
+ if (error) reject(error)
943
+ else resolve(undefined)
944
+ }
945
+
946
+ child.once("exit", onExit)
947
+ child.stdout?.on("data", onData)
948
+ child.stderr?.setEncoding("utf8").on("data", onErrorData)
949
+ })
950
+ }