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,772 @@
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 {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
14
+ import GuardianClient from "../src/guardian-client.js"
15
+ import {findAvailablePort} from "../src/port-allocator.js"
16
+
17
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
18
+ const binPath = path.join(repoRoot, "bin", "rollbridge")
19
+ const dummyAppPath = path.join(repoRoot, "test", "fixtures", "dummy-app.js")
20
+ const legacyDaemonPath = path.join(repoRoot, "test", "fixtures", "pre-split3-daemon-runner.js")
21
+
22
+ test("first pre-split package upgrade is explicitly disruptive and later replacements are atomic", async () => {
23
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-legacy-"))
24
+ const socketPath = path.join(root, "rollbridge.sock")
25
+ const nextSocketPath = path.join(root, "next.sock")
26
+ const statePath = path.join(root, "state.json")
27
+ const configPath = path.join(root, "rollbridge.cjs")
28
+ const daemonPidPath = path.join(root, "daemon.pid")
29
+ const releasePath = path.join(root, "v1")
30
+ const runtimePath = path.join(root, "runtime")
31
+ const firstPackagePath = path.join(root, "first-package")
32
+ const secondPackagePath = path.join(root, "second-package")
33
+ const proxyPort = await findAvailablePort({host: "127.0.0.1", range: {from: 25000, to: 25999}, usedPorts: new Set()})
34
+ let owner
35
+ let interruptedConnection
36
+ let retainedConnection
37
+ let blockedUpgradeGuardian
38
+ let currentControlPath = socketPath
39
+
40
+ try {
41
+ await fs.mkdir(releasePath)
42
+ await makeFifo(path.join(releasePath, "worker.fifo"))
43
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, proxyPort, statePath}))
44
+ owner = spawn(process.execPath, [legacyDaemonPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
45
+ await waitForLog(owner, "control socket listening")
46
+ assert.ok(owner.pid)
47
+ await fs.writeFile(daemonPidPath, `${owner.pid}\n`)
48
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
49
+ const legacyStatus = await sendControlCommand({command: {command: "status"}, path: socketPath})
50
+ const legacyWorkerPid = releaseProcessPid(legacyStatus, "v1", "worker")
51
+
52
+ interruptedConnection = await openWebSocket(proxyPort)
53
+ const interrupted = once(interruptedConnection, "close")
54
+ await prepareCandidatePackage(firstPackagePath)
55
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: true, proxyPort, statePath}))
56
+ const mismatchedUpgrade = await runEnsureDaemon({configPath, daemonPidPath, logPath: path.join(root, "mismatch.log"), packagePath: firstPackagePath, runtimePath})
57
+
58
+ assert.equal(mismatchedUpgrade.code, 1)
59
+ assert.match(await fs.readFile(path.join(root, "mismatch.log"), "utf8"), /legacy guardian bridge requires the incumbent config identity unchanged/)
60
+ assert.equal(interruptedConnection.destroyed, false, "config mismatch must leave the legacy listener serving")
61
+ assert.equal(releaseProcessPid(await sendControlCommand({command: {command: "status"}, path: socketPath}), "v1", "worker"), legacyWorkerPid)
62
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, proxyPort, statePath}))
63
+ blockedUpgradeGuardian = net.createServer()
64
+
65
+ await listenUnix(blockedUpgradeGuardian, `${statePath}.split3-guardian.sock`)
66
+ const blockedUpgrade = await runEnsureDaemon({configPath, daemonPidPath, logPath: path.join(root, "blocked.log"), packagePath: firstPackagePath, runtimePath})
67
+
68
+ assert.equal(blockedUpgrade.code, 1)
69
+ assert.match(await fs.readFile(path.join(root, "blocked.log"), "utf8"), /Legacy upgrade guardian socket .* already exists; refusing legacy upgrade/)
70
+ assert.equal(interruptedConnection.destroyed, false, "candidate preparation failure must leave the legacy listener serving")
71
+ assert.equal(releaseProcessPid(await sendControlCommand({command: {command: "status"}, path: socketPath}), "v1", "worker"), legacyWorkerPid)
72
+ await closeServer(blockedUpgradeGuardian)
73
+ const firstUpgrade = await run(process.execPath, [
74
+ path.join(firstPackagePath, "bin", "rollbridge"), "ensure-daemon", "--config", configPath,
75
+ "--daemon-runtime-path", runtimePath, "--daemon-log-path", path.join(root, "first.log"),
76
+ "--daemon-pid-path", daemonPidPath, "--daemon-start-timeout-ms", "3000"
77
+ ])
78
+
79
+ assert.equal(firstUpgrade.code, 0, `${firstUpgrade.stderr}\n${await fs.readFile(path.join(root, "first.log"), "utf8")}`)
80
+ assert.deepEqual(JSON.parse(firstUpgrade.stdout).ownerTransition, {
81
+ disruptive: true,
82
+ mode: "legacy-first-upgrade",
83
+ reason: "pre-split guardian and daemon lacked atomic replacement protocol"
84
+ })
85
+ await interrupted
86
+ const bridged = await sendControlCommand({command: {command: "status"}, path: socketPath})
87
+
88
+ assert.deepEqual(bridged.ownerTransition, {
89
+ disruptive: true,
90
+ mode: "legacy-first-upgrade",
91
+ reason: "pre-split guardian and daemon lacked atomic replacement protocol"
92
+ })
93
+ assert.equal(releaseProcessPid(bridged, "v1", "worker"), legacyWorkerPid)
94
+ assert.equal(bridged.activeReleaseId, "v1")
95
+
96
+ retainedConnection = await openWebSocket(proxyPort)
97
+ let retainedConnectionClosed = false
98
+ retainedConnection.once("close", () => { retainedConnectionClosed = true })
99
+ await prepareCandidatePackage(secondPackagePath)
100
+ await writeConfig(configPath, config({controlPath: nextSocketPath, extraCompanion: true, proxyPort, statePath}))
101
+ const secondUpgrade = await run(process.execPath, [
102
+ path.join(secondPackagePath, "bin", "rollbridge"), "ensure-daemon", "--config", configPath,
103
+ "--daemon-runtime-path", runtimePath, "--daemon-log-path", path.join(root, "second.log"),
104
+ "--daemon-pid-path", daemonPidPath, "--daemon-start-timeout-ms", "3000"
105
+ ])
106
+
107
+ assert.equal(secondUpgrade.code, 0, `${secondUpgrade.stderr}\n${await fs.readFile(path.join(root, "second.log"), "utf8")}`)
108
+ currentControlPath = nextSocketPath
109
+ assert.equal(retainedConnectionClosed, false, "protocol-capable replacement must retain established proxy connections")
110
+ const replaced = await sendControlCommand({command: {command: "status"}, path: nextSocketPath})
111
+
112
+ assert.equal(releaseProcessPid(replaced, "v1", "worker"), legacyWorkerPid)
113
+ assert.equal(replaced.activeReleaseId, "v1")
114
+ retainedConnection.destroy()
115
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: nextSocketPath})
116
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
117
+ await shutdown
118
+ } finally {
119
+ interruptedConnection?.destroy()
120
+ retainedConnection?.destroy()
121
+ if (blockedUpgradeGuardian?.listening) await closeServer(blockedUpgradeGuardian).catch(() => undefined)
122
+ if (owner && owner.exitCode === null && owner.signalCode === null) owner.kill("SIGKILL")
123
+ try {
124
+ const status = await sendControlCommand({command: {command: "status"}, path: currentControlPath})
125
+
126
+ if (status.activeReleaseId) {
127
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: currentControlPath}).catch(() => undefined)
128
+ await Promise.all([fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined), shutdown])
129
+ }
130
+ } catch (_error) {
131
+ // The exact fixture daemon is already stopped or failed before publishing control.
132
+ }
133
+ await stopGuardian(statePath)
134
+ await fs.rm(root, {force: true, recursive: true})
135
+ }
136
+ })
137
+
138
+ test("ensure-daemon owns and reports the exact candidate exit before readiness", async () => {
139
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-candidate-exit-"))
140
+ const configPath = path.join(root, "rollbridge.cjs")
141
+ const evidencePath = path.join(root, "candidate.json")
142
+ const packagePath = path.join(root, "candidate-package")
143
+ const runtimePath = path.join(root, "runtime")
144
+ const socketPath = path.join(root, "rollbridge.sock")
145
+ const statePath = path.join(root, "state.json")
146
+
147
+ try {
148
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
149
+ await prepareCandidatePackage(packagePath)
150
+ await installCandidateExit(packagePath, evidencePath, 47)
151
+ const ensured = await run(process.execPath, [
152
+ path.join(packagePath, "bin", "rollbridge"), "ensure-daemon", "--config", configPath,
153
+ "--daemon-runtime-path", runtimePath, "--daemon-log-path", path.join(root, "daemon.log"),
154
+ "--daemon-pid-path", path.join(root, "daemon.pid"), "--daemon-start-timeout-ms", "3000"
155
+ ])
156
+ const candidate = JSON.parse(await fs.readFile(evidencePath, "utf8"))
157
+
158
+ assert.equal(candidate.ppid, ensured.pid, "the recorded process must be the candidate spawned by this exact ensuring CLI")
159
+ assert.notEqual(candidate.pid, ensured.pid)
160
+ assert.deepEqual(candidate.argv.slice(2), ["daemon", "--config", configPath])
161
+ assert.equal(ensured.code, 1)
162
+ assert.match(ensured.stderr, new RegExp(`Rollbridge daemon candidate ${candidate.pid} exited before readiness \\(code 47, signal none\\)`))
163
+ assert.doesNotMatch(ensured.stderr, /did not become ready within/)
164
+ } finally {
165
+ await fs.rm(root, {force: true, recursive: true})
166
+ }
167
+ })
168
+
169
+ test("legacy disruptive bridge rejects non-protocol guardian failures exactly", () => {
170
+ assert.equal(isLegacyGuardianPrepareDiagnostic("Guardian prepare-owner-replacement requires a process key"), true)
171
+ assert.equal(isLegacyGuardianPrepareDiagnostic("Unknown guardian command: prepare-owner-replacement"), true)
172
+ for (const diagnostic of [
173
+ "Unknown guardian command: deploy",
174
+ "Unknown guardian command: prepare-owner-replacement ",
175
+ "Guardian authentication failed",
176
+ "connect ECONNREFUSED /tmp/guardian.sock",
177
+ "Process guardian connection closed while awaiting prepare-owner-replacement",
178
+ "Guardian owner authority mismatch",
179
+ "Malformed guardian response"
180
+ ]) assert.equal(isLegacyGuardianPrepareDiagnostic(diagnostic), false, diagnostic)
181
+ })
182
+
183
+ test("ensure-daemon atomically replaces incompatible config, socket, and package authority", async () => {
184
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-"))
185
+ const oldSocketPath = path.join(root, "old.sock")
186
+ const newSocketPath = path.join(root, "new.sock")
187
+ const statePath = path.join(root, "state.json")
188
+ const configPath = path.join(root, "rollbridge.cjs")
189
+ const runtimePath = path.join(root, "runtime")
190
+ const packagePath = path.join(root, "candidate-package")
191
+ const v1Path = path.join(root, "v1")
192
+ const v2Path = path.join(root, "v2")
193
+ let owner
194
+
195
+ try {
196
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
197
+ await makeFifo(path.join(v1Path, "worker.fifo"))
198
+ await makeFifo(path.join(v2Path, "worker.fifo"))
199
+ await writeConfig(configPath, config({controlPath: oldSocketPath, extraCompanion: false, statePath}))
200
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
201
+ await waitForLog(owner, "control socket listening")
202
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
203
+ const before = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
204
+ const oldRuntime = /** @type {{digest: string}} */ (before.daemonRuntime)
205
+ const workerPid = releaseProcessPid(before, "v1", "worker")
206
+
207
+ await prepareCandidatePackage(packagePath, {dropCommitResponse: true})
208
+ await writeConfig(configPath, config({controlPath: newSocketPath, extraCompanion: true, statePath}))
209
+ const ensured = await run(process.execPath, [
210
+ path.join(packagePath, "bin", "rollbridge"), "ensure-daemon", "--config", configPath,
211
+ "--daemon-runtime-path", runtimePath, "--daemon-log-path", path.join(root, "daemon.log"),
212
+ "--daemon-pid-path", path.join(root, "daemon.pid"), "--daemon-start-timeout-ms", "3000"
213
+ ])
214
+
215
+ const daemonLog = await fs.readFile(path.join(root, "daemon.log"), "utf8")
216
+
217
+ assert.equal(ensured.code, 0, `${ensured.stderr}\n${daemonLog}`)
218
+ const transferred = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
219
+ const newRuntime = /** @type {{digest: string, path: string}} */ (transferred.daemonRuntime)
220
+
221
+ assert.equal(transferred.activeReleaseId, "v1")
222
+ assert.deepEqual(transferred.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
223
+ assert.equal(releaseProcessPid(transferred, "v1", "worker"), workerPid)
224
+ assert.notEqual(newRuntime.digest, oldRuntime.digest)
225
+ assert.equal(path.dirname(newRuntime.path), runtimePath)
226
+
227
+ await fs.rm(packagePath, {force: true, recursive: true})
228
+ await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: newSocketPath})
229
+ const deployed = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
230
+
231
+ assert.equal(deployed.activeReleaseId, "v2")
232
+ assert.deepEqual(deployed.releaseReferences, [
233
+ {releaseId: "v1", releasePath: v1Path},
234
+ {releaseId: "v2", releasePath: v2Path}
235
+ ])
236
+ assert.equal(releaseProcessPid(deployed, "v1", "worker"), workerPid)
237
+
238
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
239
+ await Promise.all([
240
+ fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n"),
241
+ fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
242
+ ])
243
+ await shutdown
244
+ } finally {
245
+ if (owner && owner.exitCode === null && owner.signalCode === null) owner.kill("SIGKILL")
246
+ await stopGuardian(statePath)
247
+ await fs.rm(root, {force: true, recursive: true})
248
+ }
249
+ })
250
+
251
+ test("replacement refuses to overwrite an unrelated live final control socket and preserves the owner", async () => {
252
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-fence-"))
253
+ const oldSocketPath = path.join(root, "old.sock")
254
+ const occupiedSocketPath = path.join(root, "occupied.sock")
255
+ const statePath = path.join(root, "state.json")
256
+ const configPath = path.join(root, "rollbridge.cjs")
257
+ const blockerSockets = new Set()
258
+ const blocker = net.createServer((socket) => {
259
+ blockerSockets.add(socket)
260
+ socket.once("close", () => blockerSockets.delete(socket))
261
+ socket.end(`${JSON.stringify({application: "unrelated", status: "success"})}\n`)
262
+ })
263
+ let owner
264
+
265
+ try {
266
+ await fs.mkdir(path.join(root, "v1"))
267
+ await makeFifo(path.join(root, "v1", "worker.fifo"))
268
+ await writeConfig(configPath, config({controlPath: oldSocketPath, extraCompanion: false, statePath}))
269
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
270
+ await waitForLog(owner, "control socket listening")
271
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: path.join(root, "v1"), revision: "v1"}, path: oldSocketPath})
272
+ await new Promise((resolve, reject) => blocker.listen(occupiedSocketPath, () => resolve(undefined)).once("error", reject))
273
+ await writeConfig(configPath, config({controlPath: occupiedSocketPath, extraCompanion: true, statePath}))
274
+
275
+ const replacement = await run(process.execPath, [
276
+ binPath, "ensure-daemon", "--config", configPath,
277
+ "--daemon-runtime-path", path.join(root, "runtime"), "--daemon-log-path", path.join(root, "daemon.log"),
278
+ "--daemon-pid-path", path.join(root, "daemon.pid"), "--daemon-start-timeout-ms", "1500"
279
+ ])
280
+
281
+ assert.notEqual(replacement.code, 0)
282
+ assert.match(await fs.readFile(path.join(root, "daemon.log"), "utf8"), /final control socket.*already answers another live process/)
283
+ assert.equal((await sendControlCommand({command: {command: "status"}, path: oldSocketPath})).activeReleaseId, "v1")
284
+
285
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
286
+ await fs.writeFile(path.join(root, "v1", "worker.fifo"), "drained\n")
287
+ await shutdown
288
+ } finally {
289
+ if (owner && owner.exitCode === null && owner.signalCode === null) owner.kill("SIGKILL")
290
+ for (const socket of blockerSockets) socket.destroy()
291
+ if (blocker.listening) await new Promise((resolve) => blocker.close(() => resolve(undefined)))
292
+ await stopGuardian(statePath)
293
+ await fs.rm(root, {force: true, recursive: true})
294
+ }
295
+ })
296
+
297
+ test("a committed replacement crash converges from stale public state", async () => {
298
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-crash-"))
299
+ const oldSocketPath = path.join(root, "old.sock")
300
+ const newSocketPath = path.join(root, "new.sock")
301
+ const statePath = path.join(root, "state.json")
302
+ const configPath = path.join(root, "rollbridge.cjs")
303
+ const releasePath = path.join(root, "v1")
304
+ let owner
305
+ let candidate
306
+ let recovered
307
+
308
+ try {
309
+ await fs.mkdir(releasePath)
310
+ await makeFifo(path.join(releasePath, "worker.fifo"))
311
+ await writeConfig(configPath, config({controlPath: oldSocketPath, extraCompanion: false, statePath}))
312
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
313
+ await waitForLog(owner, "control socket listening")
314
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: oldSocketPath})
315
+ const staleState = await fs.readFile(statePath, "utf8")
316
+
317
+ await writeConfig(configPath, config({controlPath: newSocketPath, extraCompanion: true, statePath}))
318
+ candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
319
+ await waitForLog(candidate, "owner replacement committed")
320
+ candidate.kill("SIGKILL")
321
+ await once(candidate, "exit")
322
+ await fs.writeFile(statePath, staleState)
323
+
324
+ recovered = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
325
+ await waitForLog(recovered, "owner replacement committed")
326
+ const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
327
+
328
+ assert.equal(status.activeReleaseId, "v1")
329
+ assert.deepEqual(status.releaseReferences, [{releaseId: "v1", releasePath}])
330
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
331
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
332
+ await shutdown
333
+ } finally {
334
+ for (const child of [owner, candidate, recovered]) {
335
+ if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
336
+ }
337
+ await stopGuardian(statePath)
338
+ await fs.rm(root, {force: true, recursive: true})
339
+ }
340
+ })
341
+
342
+ test("replacement transfers an unchanged fixed proxy listener without reusePort", async () => {
343
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-fixed-proxy-"))
344
+ const oldSocketPath = path.join(root, "old.sock")
345
+ const newSocketPath = path.join(root, "new.sock")
346
+ const statePath = path.join(root, "state.json")
347
+ const configPath = path.join(root, "rollbridge.cjs")
348
+ const releasePath = path.join(root, "v1")
349
+ const proxyPort = await findAvailablePort({host: "127.0.0.1", range: {from: 24000, to: 24999}, usedPorts: new Set()})
350
+ let owner
351
+ let candidate
352
+
353
+ try {
354
+ await fs.mkdir(releasePath)
355
+ await makeFifo(path.join(releasePath, "worker.fifo"))
356
+ await writeConfig(configPath, config({controlPath: oldSocketPath, extraCompanion: false, proxyPort, statePath}))
357
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
358
+ await waitForLog(owner, "control socket listening")
359
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: oldSocketPath})
360
+ await writeConfig(configPath, config({controlPath: newSocketPath, extraCompanion: true, proxyPort, statePath}))
361
+
362
+ candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
363
+ const output = await collectUntilExitOrLog(candidate, "owner replacement committed")
364
+
365
+ assert.equal(output.message, "owner replacement committed", output.output)
366
+ const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
367
+ const proxy = /** @type {{port: number}} */ (status.proxy)
368
+
369
+ assert.equal(proxy.port, proxyPort)
370
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
371
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
372
+ await shutdown
373
+ } finally {
374
+ for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
375
+ await stopGuardian(statePath)
376
+ await fs.rm(root, {force: true, recursive: true})
377
+ }
378
+ })
379
+
380
+ test("replacement publishes an unchanged control path only after incumbent retirement", async () => {
381
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-same-control-"))
382
+ const socketPath = path.join(root, "rollbridge.sock")
383
+ const statePath = path.join(root, "state.json")
384
+ const configPath = path.join(root, "rollbridge.cjs")
385
+ const releasePath = path.join(root, "v1")
386
+ let owner
387
+ let candidate
388
+
389
+ try {
390
+ await fs.mkdir(releasePath)
391
+ await makeFifo(path.join(releasePath, "worker.fifo"))
392
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
393
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
394
+ await waitForLog(owner, "control socket listening")
395
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
396
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: true, statePath}))
397
+
398
+ candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
399
+ const output = await collectUntilExitOrLog(candidate, "owner replacement committed")
400
+
401
+ assert.equal(output.message, "owner replacement committed", output.output)
402
+ assert.equal((await sendControlCommand({command: {command: "status"}, path: socketPath})).activeReleaseId, "v1")
403
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
404
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
405
+ await shutdown
406
+ } finally {
407
+ for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
408
+ await stopGuardian(statePath)
409
+ await fs.rm(root, {force: true, recursive: true})
410
+ }
411
+ })
412
+
413
+ test("prepared replacement fences incumbent mutations until abort", async () => {
414
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-cas-"))
415
+ const socketPath = path.join(root, "rollbridge.sock")
416
+ const statePath = path.join(root, "state.json")
417
+ const configPath = path.join(root, "rollbridge.cjs")
418
+ const v1Path = path.join(root, "v1")
419
+ const v2Path = path.join(root, "v2")
420
+ let owner
421
+ let transactionClient
422
+ let v2Started = false
423
+
424
+ try {
425
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
426
+ await Promise.all([makeFifo(path.join(v1Path, "worker.fifo")), makeFifo(path.join(v2Path, "worker.fifo"))])
427
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
428
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
429
+ await waitForLog(owner, "control socket listening")
430
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: socketPath})
431
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"))
432
+ const authority = {configDigest: state.recovery.configDigest, runtime: state.daemonRuntime}
433
+
434
+ transactionClient = new GuardianClient(state.recovery.guardian)
435
+ await transactionClient.connect()
436
+ const prepared = await transactionClient.prepareOwnerReplacement(authority, {...authority, configDigest: "candidate-authority"})
437
+
438
+ await assert.rejects(
439
+ sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: socketPath})
440
+ .then((response) => {
441
+ v2Started = true
442
+ return response
443
+ }),
444
+ /replacement.*prepared|mutation.*fenced/i
445
+ )
446
+ const retained = await sendControlCommand({command: {command: "status"}, path: socketPath})
447
+ assert.equal(retained.activeReleaseId, "v1")
448
+ assert.deepEqual(retained.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
449
+ await transactionClient.abortOwnerReplacement(prepared.replacementId)
450
+ await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: socketPath})
451
+ v2Started = true
452
+ const afterAbort = await sendControlCommand({command: {command: "status"}, path: socketPath})
453
+
454
+ assert.equal(afterAbort.activeReleaseId, "v2")
455
+ assert.deepEqual(afterAbort.releaseReferences, [
456
+ {releaseId: "v1", releasePath: v1Path},
457
+ {releaseId: "v2", releasePath: v2Path}
458
+ ])
459
+ } finally {
460
+ transactionClient?.disconnect()
461
+ await new Promise((resolve) => setImmediate(resolve))
462
+ if (owner && owner.exitCode === null && owner.signalCode === null) {
463
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath}).catch(() => undefined)
464
+ const drains = [fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n").catch(() => undefined)]
465
+
466
+ if (v2Started) drains.push(fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined))
467
+ await Promise.all([...drains, shutdown])
468
+ if (owner.exitCode === null && owner.signalCode === null) owner.kill("SIGKILL")
469
+ }
470
+ await stopGuardian(statePath)
471
+ await fs.rm(root, {force: true, recursive: true})
472
+ }
473
+ })
474
+
475
+ /**
476
+ * @param {{controlPath: string, extraCompanion: boolean, proxyPort?: number, statePath: string}} options - Fixture options.
477
+ * @returns {Record<string, import("../src/json.js").JsonValue>} Raw fixture config.
478
+ */
479
+ function config({controlPath, extraCompanion, proxyPort = 0, statePath}) {
480
+ const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`
481
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ ([
482
+ {
483
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
484
+ id: "worker",
485
+ lifecycle: {drainCommand: "read released < \"$ROLLBRIDGE_RELEASE_PATH/worker.fifo\"", drainTimeoutMs: 60000},
486
+ nonBlockingDrain: true,
487
+ policy: "companion"
488
+ },
489
+ {command, health: {intervalMs: 25, path: "/ping", timeoutMs: 3000}, id: "web", policy: "proxied", port: {from: 0, to: 0}}
490
+ ])
491
+
492
+ if (extraCompanion) processes.splice(1, 0, {command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`, id: "metrics", policy: "companion"})
493
+ return {
494
+ application: "owner-replacement-test",
495
+ control: {path: controlPath},
496
+ ownerRecovery: {reconnectGraceMs: 3000},
497
+ processes,
498
+ proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: proxyPort},
499
+ statePath
500
+ }
501
+ }
502
+
503
+ /**
504
+ * @param {string} destination - Candidate package path.
505
+ * @param {{dropCommitResponse?: boolean}} [options] - Fault injection.
506
+ * @returns {Promise<void>} Package preparation completion.
507
+ */
508
+ async function prepareCandidatePackage(destination, options = {}) {
509
+ const {dropCommitResponse = false} = options
510
+
511
+ await fs.mkdir(destination, {recursive: true})
512
+ await Promise.all([
513
+ fs.cp(path.join(repoRoot, "bin"), path.join(destination, "bin"), {recursive: true}),
514
+ fs.cp(path.join(repoRoot, "src"), path.join(destination, "src"), {recursive: true}),
515
+ fs.cp(path.join(repoRoot, "node_modules"), path.join(destination, "node_modules"), {dereference: true, recursive: true}),
516
+ fs.copyFile(path.join(repoRoot, "package.json"), path.join(destination, "package.json"))
517
+ ])
518
+ await fs.appendFile(path.join(destination, "src", "cli.js"), "\n// distinct owner-replacement candidate closure\n")
519
+ if (dropCommitResponse) {
520
+ const clientPath = path.join(destination, "src", "control-client.js")
521
+ const source = await fs.readFile(clientPath, "utf8")
522
+ const marker = "export async function sendControlCommand({command, path}) {\n"
523
+ const injected = `${marker} if (command.command === "commit-owner-replacement") {\n return await new Promise((resolve, reject) => {\n const socket = net.createConnection(path)\n socket.once("error", reject)\n socket.once("data", () => { socket.destroy(); reject(new Error("injected lost commit response")) })\n socket.once("connect", () => socket.write(\`${"${JSON.stringify(command)}"}\\n\`))\n })\n }\n\n`
524
+ const sessionMarker = " this.socket.write(`${JSON.stringify(command)}\\n`)\n return await response\n"
525
+ const sessionInjection = " this.socket.write(`${JSON.stringify(command)}\\n`)\n const result = await response\n if (command.command === \"commit-owner-replacement\") throw new Error(\"injected lost commit response\")\n return result\n"
526
+
527
+ assert.ok(source.includes(marker))
528
+ assert.ok(source.includes(sessionMarker))
529
+ await fs.writeFile(clientPath, source.replace(marker, injected).replace(sessionMarker, sessionInjection))
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Replaces only the copied package's daemon entry with an exact early-exit fixture.
535
+ * @param {string} packagePath - Copied candidate package root.
536
+ * @param {string} evidencePath - Exact candidate identity record.
537
+ * @param {number} exitCode - Distinct candidate exit code.
538
+ * @returns {Promise<void>} Fixture installation completion.
539
+ */
540
+ async function installCandidateExit(packagePath, evidencePath, exitCode) {
541
+ const binPath = path.join(packagePath, "bin", "rollbridge")
542
+ const source = `#!/usr/bin/env node
543
+ import fs from "node:fs"
544
+
545
+ if (process.argv[2] === "daemon") {
546
+ fs.writeFileSync(${JSON.stringify(evidencePath)}, JSON.stringify({argv: process.argv, pid: process.pid, ppid: process.ppid}))
547
+ process.exit(${exitCode})
548
+ }
549
+
550
+ const {runCli} = await import("../src/cli.js")
551
+ await runCli(process.argv)
552
+ `
553
+
554
+ await fs.writeFile(binPath, source, {mode: 0o755})
555
+ }
556
+
557
+ /**
558
+ * @param {{configPath: string, daemonPidPath: string, logPath: string, packagePath: string, runtimePath: string}} options - Ensure fixture paths.
559
+ * @returns {Promise<{code: number, stderr: string, stdout: string}>} Ensure result.
560
+ */
561
+ async function runEnsureDaemon({configPath, daemonPidPath, logPath, packagePath, runtimePath}) {
562
+ return await run(process.execPath, [
563
+ path.join(packagePath, "bin", "rollbridge"), "ensure-daemon", "--config", configPath,
564
+ "--daemon-runtime-path", runtimePath, "--daemon-log-path", logPath,
565
+ "--daemon-pid-path", daemonPidPath, "--daemon-start-timeout-ms", "3000"
566
+ ])
567
+ }
568
+
569
+ /**
570
+ * @param {net.Server} server - Unix server.
571
+ * @param {string} socketPath - Unix socket path.
572
+ * @returns {Promise<void>} Listen completion.
573
+ */
574
+ async function listenUnix(server, socketPath) {
575
+ await new Promise((resolve, reject) => {
576
+ server.once("error", reject)
577
+ server.listen(socketPath, () => resolve(undefined))
578
+ })
579
+ }
580
+
581
+ /**
582
+ * @param {net.Server} server - Server to close.
583
+ * @returns {Promise<void>} Close completion.
584
+ */
585
+ async function closeServer(server) {
586
+ await new Promise((resolve) => server.close(() => resolve(undefined)))
587
+ }
588
+
589
+ /**
590
+ * @param {string} fifoPath - FIFO path.
591
+ * @returns {Promise<void>} FIFO creation completion.
592
+ */
593
+ async function makeFifo(fifoPath) {
594
+ const child = spawn("mkfifo", [fifoPath])
595
+ assert.equal((await once(child, "exit"))[0], 0)
596
+ }
597
+
598
+ /**
599
+ * @param {Record<string, import("../src/json.js").JsonValue>} status - Status.
600
+ * @param {string} releaseId - Release id.
601
+ * @param {string} processId - Process id.
602
+ * @returns {number} Managed process pid.
603
+ */
604
+ function releaseProcessPid(status, releaseId, processId) {
605
+ const releases = /** @type {{releaseId: string, processes: {id: string, pid?: number}[]}[]} */ (status.releases)
606
+ const pid = releases.find((release) => release.releaseId === releaseId)?.processes.find((processStatus) => processStatus.id === processId)?.pid
607
+
608
+ if (typeof pid !== "number") throw new Error(`Missing ${processId} pid for release ${releaseId}`)
609
+ return pid
610
+ }
611
+
612
+ /**
613
+ * @param {string} command - Executable.
614
+ * @param {string[]} args - Arguments.
615
+ * @returns {Promise<{code: number, pid: number, stderr: string, stdout: string}>} Child result.
616
+ */
617
+ async function run(command, args) {
618
+ const child = spawn(command, args, {stdio: ["ignore", "pipe", "pipe"]})
619
+ let stdout = ""
620
+ let stderr = ""
621
+
622
+ child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk })
623
+ child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk })
624
+ const [code] = await once(child, "exit")
625
+ assert.ok(child.pid)
626
+ return {code, pid: child.pid, stderr, stdout}
627
+ }
628
+
629
+ /**
630
+ * @param {import("node:child_process").ChildProcess} child - Daemon.
631
+ * @param {string} message - Log message.
632
+ * @returns {Promise<void>} Resolves after the matching log event.
633
+ */
634
+ async function waitForLog(child, message) {
635
+ assert.ok(child.stdout)
636
+ assert.ok(child.stderr)
637
+ child.stdout.setEncoding("utf8")
638
+ child.stderr.setEncoding("utf8")
639
+ await new Promise((resolve, reject) => {
640
+ let buffer = ""
641
+ let errors = ""
642
+ const onExit = () => finish(new Error(`daemon exited before ${message}: ${errors}`))
643
+ const onData = (/** @type {string} */ chunk) => {
644
+ buffer += chunk
645
+ for (const line of buffer.split("\n")) {
646
+ if (line && JSON.parse(line).message === message) return finish(undefined)
647
+ }
648
+ }
649
+ const finish = (/** @type {Error | undefined} */ error) => {
650
+ child.off("exit", onExit)
651
+ child.stdout?.off("data", onData)
652
+ child.stderr?.off("data", onError)
653
+ if (error) reject(error)
654
+ else resolve(undefined)
655
+ }
656
+
657
+ child.once("exit", onExit)
658
+ child.stdout?.on("data", onData)
659
+ const onError = (/** @type {string} */ chunk) => { errors += chunk }
660
+
661
+ child.stderr?.on("data", onError)
662
+ })
663
+ }
664
+
665
+ /**
666
+ * @param {import("node:child_process").ChildProcess} child - Daemon.
667
+ * @param {string} message - Expected log message.
668
+ * @returns {Promise<{message?: string, output: string}>} Exit output or matched message.
669
+ */
670
+ async function collectUntilExitOrLog(child, message) {
671
+ assert.ok(child.stdout)
672
+ assert.ok(child.stderr)
673
+ const stdout = child.stdout
674
+ const stderr = child.stderr
675
+
676
+ stdout.setEncoding("utf8")
677
+ stderr.setEncoding("utf8")
678
+ let output = ""
679
+
680
+ return await new Promise((resolve) => {
681
+ const onData = (/** @type {string} */ chunk) => {
682
+ output += chunk
683
+ for (const line of output.split("\n")) {
684
+ if (!line) continue
685
+ try {
686
+ if (JSON.parse(line).message === message) return finish(message)
687
+ } catch (_error) {
688
+ // Non-JSON stderr remains part of the assertion diagnostic.
689
+ }
690
+ }
691
+ }
692
+ const finish = (/** @type {string | undefined} */ matched) => {
693
+ child.off("exit", onExit)
694
+ stdout.off("data", onData)
695
+ stderr.off("data", onData)
696
+ resolve({message: matched, output})
697
+ }
698
+ const onExit = () => finish(undefined)
699
+
700
+ child.once("exit", onExit)
701
+ stdout.on("data", onData)
702
+ stderr.on("data", onData)
703
+ })
704
+ }
705
+
706
+ /**
707
+ * Opens a live WebSocket through the Rollbridge proxy without a client dependency.
708
+ * @param {number} port - Proxy port.
709
+ * @returns {Promise<net.Socket>} Upgraded socket.
710
+ */
711
+ async function openWebSocket(port) {
712
+ const socket = net.createConnection({host: "127.0.0.1", port})
713
+
714
+ await once(socket, "connect")
715
+ socket.write([
716
+ "GET /socket HTTP/1.1",
717
+ "Host: 127.0.0.1",
718
+ "Connection: Upgrade",
719
+ "Upgrade: websocket",
720
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
721
+ "Sec-WebSocket-Version: 13",
722
+ "\r\n"
723
+ ].join("\r\n"))
724
+ const [response] = await once(socket, "data")
725
+
726
+ assert.match(String(response), /^HTTP\/1\.1 101 /)
727
+ return socket
728
+ }
729
+
730
+ /**
731
+ * @param {string} configPath - Config path.
732
+ * @param {Record<string, import("../src/json.js").JsonValue>} value - Config.
733
+ * @returns {Promise<void>} Config write completion.
734
+ */
735
+ async function writeConfig(configPath, value) {
736
+ await fs.writeFile(configPath, `module.exports = ${JSON.stringify(value, null, 2)}\n`)
737
+ }
738
+
739
+ /** @param {string} statePath - State path. */
740
+ async function stopGuardian(statePath) {
741
+ try {
742
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"))
743
+ const identity = state.recovery?.guardian
744
+ const pid = identity?.pid
745
+
746
+ if (identity?.socketPath && identity.token) {
747
+ const client = new GuardianClient(identity)
748
+
749
+ try {
750
+ await client.connect()
751
+ for (const entry of await client.inventory()) {
752
+ if (entry.status.pid) {
753
+ try { process.kill(-entry.status.pid, "SIGKILL") } catch (error) {
754
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
755
+ }
756
+ }
757
+ }
758
+ } finally {
759
+ client.disconnect()
760
+ }
761
+ }
762
+
763
+ if (typeof pid === "number") {
764
+ const command = (await fs.readFile(`/proc/${pid}/cmdline`, "utf8")).replaceAll("\0", " ")
765
+
766
+ if (!command.includes("process-guardian.js") || !command.includes(statePath)) throw new Error(`Refusing to stop unverified fixture guardian pid ${pid}`)
767
+ process.kill(pid, "SIGKILL")
768
+ }
769
+ } catch (error) {
770
+ if (!error || typeof error !== "object" || !("code" in error) || !["ENOENT", "ESRCH"].includes(String(error.code))) throw error
771
+ }
772
+ }