rollbridge 0.1.28 → 0.1.29

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