rollbridge 0.1.36 → 0.1.38

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.
@@ -10,7 +10,7 @@ import path from "node:path"
10
10
  import test from "node:test"
11
11
  import {fileURLToPath} from "node:url"
12
12
  import {normalizeConfig} from "../src/config.js"
13
- import {sendControlCommand} from "../src/control-client.js"
13
+ import {openControlSession, sendControlCommand} from "../src/control-client.js"
14
14
  import RollbridgeDaemon, {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
15
15
  import GuardianClient from "../src/guardian-client.js"
16
16
  import {findAvailablePort} from "../src/port-allocator.js"
@@ -19,6 +19,476 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."
19
19
  const binPath = path.join(repoRoot, "bin", "rollbridge")
20
20
  const dummyAppPath = path.join(repoRoot, "test", "fixtures", "dummy-app.js")
21
21
  const legacyDaemonPath = path.join(repoRoot, "test", "fixtures", "pre-split3-daemon-runner.js")
22
+ const partialGuardianPath = path.join(repoRoot, "test", "fixtures", "partial-owner-replacement-process-guardian.js")
23
+
24
+ test("partial owner-replacement guardian crosses the authenticated legacy bridge before retired-owner commit", async () => {
25
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-partial-"))
26
+ const socketPath = path.join(root, "rollbridge.sock")
27
+ const statePath = path.join(root, "state.json")
28
+ const configPath = path.join(root, "rollbridge.cjs")
29
+ const daemonPidPath = path.join(root, "daemon.pid")
30
+ const releasePath = path.join(root, "v1")
31
+ const packagePath = path.join(root, "candidate-package")
32
+ const runtimePath = path.join(root, "runtime")
33
+ const partialSocketPath = path.join(root, "partial-guardian.sock")
34
+ let owner
35
+ let partialGuardian
36
+ let retainedConnection
37
+ let backendGuardianIdentity
38
+
39
+ try {
40
+ await fs.mkdir(releasePath)
41
+ await makeFifo(path.join(releasePath, "worker.fifo"))
42
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
43
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
44
+ await waitForLog(owner, "control socket listening")
45
+ assert.ok(owner.pid)
46
+ await fs.writeFile(daemonPidPath, `${owner.pid}\n`)
47
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
48
+ const before = await sendControlCommand({command: {command: "status"}, path: socketPath})
49
+ const workerPid = releaseProcessPid(before, "v1", "worker")
50
+ const webPid = releaseProcessPid(before, "v1", "web")
51
+ const proxyPort = /** @type {{port?: number}} */ (before.proxy).port
52
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"))
53
+
54
+ if (typeof proxyPort !== "number") throw new Error("Partial guardian fixture proxy is missing its bound port")
55
+
56
+ backendGuardianIdentity = {...state.recovery.guardian}
57
+ partialGuardian = await startPartialGuardian({
58
+ backendPath: state.recovery.guardian.socketPath,
59
+ mode: "partial",
60
+ socketPath: partialSocketPath,
61
+ token: state.recovery.guardian.token
62
+ })
63
+ state.recovery.guardian = {...state.recovery.guardian, pid: partialGuardian.pid, socketPath: partialSocketPath}
64
+ await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
65
+ retainedConnection = await openWebSocket(proxyPort)
66
+ const retainedClosed = once(retainedConnection, "close")
67
+ await fs.rm(socketPath)
68
+ await prepareCandidatePackage(packagePath)
69
+ const ownerExit = once(owner, "exit")
70
+ const ensured = await runEnsureDaemon({configPath, daemonPidPath, logPath: path.join(root, "candidate.log"), packagePath, runtimePath})
71
+
72
+ assert.equal(ensured.code, 0, `${ensured.stderr}\n${await fs.readFile(path.join(root, "candidate.log"), "utf8")}`)
73
+ assert.deepEqual(await ownerExit, [null, "SIGKILL"], "the exact authenticated incumbent boundary is crossed once")
74
+ await retainedClosed
75
+ const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
76
+
77
+ assert.equal(status.activeReleaseId, "v1")
78
+ assert.equal(releaseProcessPid(status, "v1", "worker"), workerPid)
79
+ assert.equal(releaseProcessPid(status, "v1", "web"), webPid)
80
+ assert.deepEqual(status.ownerTransition, {
81
+ disruptive: true,
82
+ mode: "legacy-first-upgrade",
83
+ reason: "retained guardian and daemon lacked atomic replacement protocol"
84
+ })
85
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
86
+
87
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
88
+ await shutdown
89
+ } finally {
90
+ retainedConnection?.destroy()
91
+ if (owner && owner.exitCode === null && owner.signalCode === null) {
92
+ const exited = once(owner, "exit")
93
+
94
+ owner.kill("SIGKILL")
95
+ await exited
96
+ }
97
+ partialGuardian?.kill("SIGTERM")
98
+ if (partialGuardian && partialGuardian.exitCode === null && partialGuardian.signalCode === null) await once(partialGuardian, "exit")
99
+ if (backendGuardianIdentity) {
100
+ const state = JSON.parse(await fs.readFile(statePath, "utf8").catch(() => "null"))
101
+
102
+ if (state?.recovery?.guardian?.socketPath === partialSocketPath) {
103
+ state.recovery.guardian = backendGuardianIdentity
104
+ await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
105
+ }
106
+ }
107
+ await stopGuardian(statePath)
108
+ await fs.rm(root, {force: true, recursive: true})
109
+ }
110
+ })
111
+
112
+ test("partial guardian replacement remains fenced through coordinator reconstruction", async () => {
113
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-partial-fence-"))
114
+ const socketPath = path.join(root, "rollbridge.sock")
115
+ const statePath = path.join(root, "state.json")
116
+ const configPath = path.join(root, "rollbridge.cjs")
117
+ const releasePath = path.join(root, "v1")
118
+ const partialSocketPath = path.join(root, "partial-guardian.sock")
119
+ const restoreStarted = deferred()
120
+ const continueRestore = deferred()
121
+ let owner
122
+ let partialGuardian
123
+ let candidate = /** @type {RollbridgeDaemon | undefined} */ (undefined)
124
+ let contender = /** @type {GuardianClient | undefined} */ (undefined)
125
+ let backendGuardianIdentity
126
+ let replacementPromise = /** @type {Promise<void> | undefined} */ (undefined)
127
+
128
+ try {
129
+ await fs.mkdir(releasePath)
130
+ await makeFifo(path.join(releasePath, "worker.fifo"))
131
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
132
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
133
+ await waitForLog(owner, "control socket listening")
134
+ assert.ok(owner.pid)
135
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
136
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"))
137
+
138
+ backendGuardianIdentity = {...state.recovery.guardian}
139
+ partialGuardian = await startPartialGuardian({
140
+ backendPath: backendGuardianIdentity.socketPath,
141
+ mode: "partial",
142
+ socketPath: partialSocketPath,
143
+ token: backendGuardianIdentity.token
144
+ })
145
+ state.recovery.guardian = {...backendGuardianIdentity, pid: partialGuardian.pid, socketPath: partialSocketPath}
146
+ await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
147
+ const replacement = new RollbridgeDaemon({
148
+ config: normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath})),
149
+ configPath,
150
+ legacyIncumbentPid: owner.pid,
151
+ logger: () => {}
152
+ })
153
+ candidate = replacement
154
+ replacement.restoreOwnerState = async () => {
155
+ restoreStarted.resolve(undefined)
156
+ await continueRestore.promise
157
+ throw new Error("injected reconstruction stop after fence audit")
158
+ }
159
+ replacementPromise = replacement.replaceIncompatibleOwner()
160
+ void replacementPromise.catch(() => {})
161
+ await restoreStarted.promise
162
+ assert.equal(JSON.parse(await fs.readFile(statePath, "utf8")).recovery.guardian.socketPath, partialSocketPath)
163
+
164
+ let mutationError
165
+
166
+ try {
167
+ await sendControlCommand({command: {command: "restart", processId: "missing"}, path: socketPath})
168
+ } catch (error) {
169
+ mutationError = error
170
+ }
171
+ contender = new GuardianClient({pid: partialGuardian.pid, socketPath: partialSocketPath, token: backendGuardianIdentity.token})
172
+ await contender.connect()
173
+ let contenderError
174
+ let contenderPrepared
175
+
176
+ try {
177
+ contenderPrepared = await contender.prepareOwnerReplacement(
178
+ {configDigest: state.recovery.configDigest, runtime: state.daemonRuntime ?? null},
179
+ {configDigest: "contender", runtime: null}
180
+ )
181
+ } catch (error) {
182
+ contenderError = error
183
+ }
184
+ if (contenderPrepared) await contender.abortOwnerReplacement(contenderPrepared.replacementId)
185
+ contender.disconnect()
186
+ contender = undefined
187
+ continueRestore.resolve(undefined)
188
+ await assert.rejects(replacementPromise, /injected reconstruction stop after fence audit/)
189
+ replacementPromise = undefined
190
+ assert.match(mutationError instanceof Error ? mutationError.message : "", /fenced while an owner replacement is prepared/)
191
+ assert.match(contenderError instanceof Error ? contenderError.message : "", /another owner replacement candidate is already prepared/i)
192
+ assert.notEqual(JSON.parse(await fs.readFile(statePath, "utf8")).recovery.guardian.socketPath, `${statePath}.split3-guardian.sock`)
193
+ assert.equal((await sendControlCommand({command: {command: "status"}, path: socketPath})).daemonPid, owner.pid)
194
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
195
+
196
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
197
+ await shutdown
198
+ } finally {
199
+ continueRestore.resolve(undefined)
200
+ contender?.disconnect()
201
+ await replacementPromise?.catch(() => undefined)
202
+ candidate?.guardian?.disconnect()
203
+ if (owner && owner.exitCode === null && owner.signalCode === null) {
204
+ const exited = once(owner, "exit")
205
+
206
+ owner.kill("SIGKILL")
207
+ await exited
208
+ }
209
+ partialGuardian?.kill("SIGTERM")
210
+ if (partialGuardian && partialGuardian.exitCode === null && partialGuardian.signalCode === null) await once(partialGuardian, "exit")
211
+ if (backendGuardianIdentity) {
212
+ const state = JSON.parse(await fs.readFile(statePath, "utf8").catch(() => "null"))
213
+
214
+ if (state?.recovery?.guardian?.socketPath === partialSocketPath) {
215
+ state.recovery.guardian = backendGuardianIdentity
216
+ await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
217
+ }
218
+ }
219
+ await stopGuardian(statePath)
220
+ await fs.rm(root, {force: true, recursive: true})
221
+ }
222
+ })
223
+
224
+ test("partial guardian replacement persists the coordinator only after ownership confirmation", async () => {
225
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-partial-persist-"))
226
+ const socketPath = path.join(root, "rollbridge.sock")
227
+ const statePath = path.join(root, "state.json")
228
+ const configPath = path.join(root, "rollbridge.cjs")
229
+ const releasePath = path.join(root, "v1")
230
+ const partialSocketPath = path.join(root, "partial-guardian.sock")
231
+ const ownershipConfirmationStarted = deferred()
232
+ const continueOwnershipConfirmation = deferred()
233
+ let owner
234
+ let partialGuardian
235
+ let candidate = /** @type {RollbridgeDaemon | undefined} */ (undefined)
236
+ let backendGuardianIdentity
237
+ let replacementPromise = /** @type {Promise<void> | undefined} */ (undefined)
238
+
239
+ try {
240
+ await fs.mkdir(releasePath)
241
+ await makeFifo(path.join(releasePath, "worker.fifo"))
242
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
243
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
244
+ await waitForLog(owner, "control socket listening")
245
+ assert.ok(owner.pid)
246
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
247
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"))
248
+
249
+ backendGuardianIdentity = {...state.recovery.guardian}
250
+ partialGuardian = await startPartialGuardian({
251
+ backendPath: backendGuardianIdentity.socketPath,
252
+ mode: "partial",
253
+ socketPath: partialSocketPath,
254
+ token: backendGuardianIdentity.token
255
+ })
256
+ state.recovery.guardian = {...backendGuardianIdentity, pid: partialGuardian.pid, socketPath: partialSocketPath}
257
+ await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
258
+ const replacement = new RollbridgeDaemon({
259
+ config: normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath})),
260
+ configPath,
261
+ legacyIncumbentPid: owner.pid,
262
+ logger: () => {}
263
+ })
264
+ const restoreOwnerState = replacement.restoreOwnerState.bind(replacement)
265
+
266
+ candidate = replacement
267
+ replacement.restoreOwnerState = async (...args) => {
268
+ await restoreOwnerState(...args)
269
+ const coordinator = replacement.guardian
270
+
271
+ if (!coordinator) throw new Error("Partial guardian persistence fixture is missing its coordinator")
272
+ const completeLegacyOwnerClaim = coordinator.completeLegacyOwnerClaim.bind(coordinator)
273
+
274
+ coordinator.completeLegacyOwnerClaim = async (replacementId) => {
275
+ ownershipConfirmationStarted.resolve(undefined)
276
+ await continueOwnershipConfirmation.promise
277
+ await completeLegacyOwnerClaim(replacementId)
278
+ }
279
+ }
280
+ replacementPromise = replacement.replaceIncompatibleOwner()
281
+ void replacementPromise.catch(() => {})
282
+ await ownershipConfirmationStarted.promise
283
+ assert.notEqual(
284
+ JSON.parse(await fs.readFile(statePath, "utf8")).recovery.guardian.socketPath,
285
+ `${statePath}.split3-guardian.sock`,
286
+ "durable state must not name the coordinator before its legacy ownership claim is confirmed"
287
+ )
288
+ continueOwnershipConfirmation.resolve(undefined)
289
+ await replacementPromise
290
+ replacementPromise = undefined
291
+ const shutdown = replacement.shutdown()
292
+
293
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
294
+ await shutdown
295
+ candidate = undefined
296
+ } finally {
297
+ continueOwnershipConfirmation.resolve(undefined)
298
+ await replacementPromise?.catch(() => undefined)
299
+ if (candidate?.controlServer || candidate?.proxyServer) {
300
+ const shutdown = candidate.shutdown().catch(() => undefined)
301
+
302
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)
303
+ await shutdown
304
+ }
305
+ candidate?.guardian?.disconnect()
306
+ if (owner && owner.exitCode === null && owner.signalCode === null) {
307
+ const exited = once(owner, "exit")
308
+
309
+ owner.kill("SIGKILL")
310
+ await exited
311
+ }
312
+ partialGuardian?.kill("SIGTERM")
313
+ if (partialGuardian && partialGuardian.exitCode === null && partialGuardian.signalCode === null) await once(partialGuardian, "exit")
314
+ if (backendGuardianIdentity) {
315
+ const state = JSON.parse(await fs.readFile(statePath, "utf8").catch(() => "null"))
316
+
317
+ if (state?.recovery?.guardian?.socketPath === partialSocketPath) {
318
+ state.recovery.guardian = backendGuardianIdentity
319
+ await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
320
+ }
321
+ }
322
+ await stopGuardian(statePath)
323
+ await fs.rm(root, {force: true, recursive: true})
324
+ }
325
+ })
326
+
327
+ test("failed partial upgrade resumes an incumbent retired release drain", {timeout: 5000}, async () => {
328
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-partial-drain-"))
329
+ const socketPath = path.join(root, "rollbridge.sock")
330
+ const statePath = path.join(root, "state.json")
331
+ const configPath = path.join(root, "rollbridge.cjs")
332
+ const v1Path = path.join(root, "v1")
333
+ const v2Path = path.join(root, "v2")
334
+ const partialSocketPath = path.join(root, "partial-guardian.sock")
335
+ let owner
336
+ let partialGuardian
337
+ let retainedConnection
338
+ let candidate = /** @type {RollbridgeDaemon | undefined} */ (undefined)
339
+ let backendGuardianIdentity
340
+
341
+ try {
342
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
343
+ await Promise.all([makeFifo(path.join(v1Path, "worker.fifo")), makeFifo(path.join(v2Path, "worker.fifo"))])
344
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
345
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
346
+ await waitForLog(owner, "control socket listening")
347
+ assert.ok(owner.pid)
348
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: socketPath})
349
+ const v1Status = await sendControlCommand({command: {command: "status"}, path: socketPath})
350
+ const proxyPort = /** @type {{port?: number}} */ (v1Status.proxy).port
351
+
352
+ if (typeof proxyPort !== "number") throw new Error("Partial guardian drain fixture proxy is missing its bound port")
353
+ retainedConnection = await openWebSocket(proxyPort)
354
+ await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: socketPath})
355
+ const draining = await sendControlCommand({command: {command: "status"}, path: socketPath})
356
+
357
+ assert.equal(releaseState(draining, "v1"), "draining")
358
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"))
359
+
360
+ backendGuardianIdentity = {...state.recovery.guardian}
361
+ partialGuardian = await startPartialGuardian({
362
+ backendPath: backendGuardianIdentity.socketPath,
363
+ mode: "wrong-provenance",
364
+ socketPath: partialSocketPath,
365
+ token: backendGuardianIdentity.token
366
+ })
367
+ state.recovery.guardian = {...backendGuardianIdentity, pid: partialGuardian.pid, socketPath: partialSocketPath}
368
+ await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
369
+ const replacement = new RollbridgeDaemon({
370
+ config: normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath})),
371
+ configPath,
372
+ legacyIncumbentPid: owner.pid,
373
+ logger: () => {}
374
+ })
375
+
376
+ candidate = replacement
377
+ await assert.rejects(() => replacement.replaceIncompatibleOwner(), /provenance mismatch/)
378
+ assert.equal(owner.exitCode, null)
379
+ assert.equal(owner.signalCode, null)
380
+ const releaseDrained = waitForLog(owner, "release drained")
381
+
382
+ retainedConnection.destroy()
383
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
384
+ await releaseDrained
385
+ assert.equal(releaseState(await sendControlCommand({command: {command: "status"}, path: socketPath}), "v1"), "stopped")
386
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
387
+
388
+ await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
389
+ await shutdown
390
+ } finally {
391
+ retainedConnection?.destroy()
392
+ candidate?.guardian?.disconnect()
393
+ if (owner && owner.exitCode === null && owner.signalCode === null) owner.kill("SIGKILL")
394
+ partialGuardian?.kill("SIGTERM")
395
+ if (partialGuardian && partialGuardian.exitCode === null && partialGuardian.signalCode === null) await once(partialGuardian, "exit")
396
+ if (backendGuardianIdentity) {
397
+ const state = JSON.parse(await fs.readFile(statePath, "utf8").catch(() => "null"))
398
+
399
+ if (state?.recovery?.guardian?.socketPath === partialSocketPath) {
400
+ state.recovery.guardian = backendGuardianIdentity
401
+ await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
402
+ }
403
+ }
404
+ await stopGuardian(statePath)
405
+ await fs.rm(root, {force: true, recursive: true})
406
+ }
407
+ })
408
+
409
+ test("partial guardian classification failures preserve the incumbent, children, and retained stream", async (t) => {
410
+ for (const fault of ["malformed-capability", "wrong-pid", "wrong-provenance"]) {
411
+ await t.test(fault, async () => {
412
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), `rollbridge-owner-replacement-partial-${fault}-`))
413
+ const socketPath = path.join(root, "rollbridge.sock")
414
+ const statePath = path.join(root, "state.json")
415
+ const configPath = path.join(root, "rollbridge.cjs")
416
+ const releasePath = path.join(root, "v1")
417
+ const partialSocketPath = path.join(root, "partial-guardian.sock")
418
+ let owner
419
+ let partialGuardian
420
+ let retainedConnection
421
+ let candidate = /** @type {RollbridgeDaemon | undefined} */ (undefined)
422
+
423
+ try {
424
+ await fs.mkdir(releasePath)
425
+ await makeFifo(path.join(releasePath, "worker.fifo"))
426
+ await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
427
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
428
+ await waitForLog(owner, "control socket listening")
429
+ assert.ok(owner.pid)
430
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
431
+ const before = await sendControlCommand({command: {command: "status"}, path: socketPath})
432
+ const processPids = [releaseProcessPid(before, "v1", "worker"), releaseProcessPid(before, "v1", "web")]
433
+ const proxyPort = /** @type {{port?: number}} */ (before.proxy).port
434
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"))
435
+ const backendIdentity = {...state.recovery.guardian}
436
+
437
+ if (typeof proxyPort !== "number") throw new Error("Partial guardian failure fixture proxy is missing its bound port")
438
+ partialGuardian = await startPartialGuardian({
439
+ backendPath: backendIdentity.socketPath,
440
+ mode: fault === "wrong-provenance" ? fault : fault === "malformed-capability" ? fault : "partial",
441
+ socketPath: partialSocketPath,
442
+ token: backendIdentity.token
443
+ })
444
+ state.recovery.guardian = {
445
+ ...backendIdentity,
446
+ pid: fault === "wrong-pid" ? backendIdentity.pid : partialGuardian.pid,
447
+ socketPath: partialSocketPath
448
+ }
449
+ await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
450
+ retainedConnection = await openWebSocket(proxyPort)
451
+ let retainedClosed = false
452
+
453
+ retainedConnection.once("close", () => { retainedClosed = true })
454
+ const replacement = new RollbridgeDaemon({
455
+ config: normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath})),
456
+ configPath,
457
+ legacyIncumbentPid: owner.pid,
458
+ logger: () => {}
459
+ })
460
+ candidate = replacement
461
+ const expected = fault === "malformed-capability"
462
+ ? /invalid owner-replacement capability response/
463
+ : fault === "wrong-pid"
464
+ ? /does not own socket|does not match the retained guardian command and socket/
465
+ : /provenance mismatch/
466
+
467
+ await assert.rejects(() => replacement.replaceIncompatibleOwner(), expected)
468
+ assert.equal(owner.exitCode, null)
469
+ assert.equal(owner.signalCode, null)
470
+ assert.equal(retainedClosed, false)
471
+ assert.equal(retainedConnection.destroyed, false)
472
+ for (const pid of processPids) assert.doesNotThrow(() => process.kill(pid, 0))
473
+ assert.equal(releaseProcessPid(await sendControlCommand({command: {command: "status"}, path: socketPath}), "v1", "worker"), processPids[0])
474
+ await assert.rejects(fs.access(`${statePath}.split3-guardian.sock`), {code: "ENOENT"})
475
+
476
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
477
+
478
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
479
+ await shutdown
480
+ } finally {
481
+ retainedConnection?.destroy()
482
+ candidate?.guardian?.disconnect()
483
+ if (owner && owner.exitCode === null && owner.signalCode === null) owner.kill("SIGKILL")
484
+ partialGuardian?.kill("SIGTERM")
485
+ if (partialGuardian && partialGuardian.exitCode === null && partialGuardian.signalCode === null) await once(partialGuardian, "exit")
486
+ await stopGuardian(statePath)
487
+ await fs.rm(root, {force: true, recursive: true})
488
+ }
489
+ })
490
+ }
491
+ })
22
492
 
23
493
  test("first pre-split package upgrade is explicitly disruptive and later replacements are atomic", async () => {
24
494
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-legacy-"))
@@ -81,7 +551,7 @@ test("first pre-split package upgrade is explicitly disruptive and later replace
81
551
  assert.deepEqual(JSON.parse(firstUpgrade.stdout).ownerTransition, {
82
552
  disruptive: true,
83
553
  mode: "legacy-first-upgrade",
84
- reason: "pre-split guardian and daemon lacked atomic replacement protocol"
554
+ reason: "retained guardian and daemon lacked atomic replacement protocol"
85
555
  })
86
556
  await interrupted
87
557
  const bridged = await sendControlCommand({command: {command: "status"}, path: socketPath})
@@ -89,7 +559,7 @@ test("first pre-split package upgrade is explicitly disruptive and later replace
89
559
  assert.deepEqual(bridged.ownerTransition, {
90
560
  disruptive: true,
91
561
  mode: "legacy-first-upgrade",
92
- reason: "pre-split guardian and daemon lacked atomic replacement protocol"
562
+ reason: "retained guardian and daemon lacked atomic replacement protocol"
93
563
  })
94
564
  assert.equal(releaseProcessPid(bridged, "v1", "worker"), legacyWorkerPid)
95
565
  assert.equal(bridged.activeReleaseId, "v1")
@@ -718,6 +1188,88 @@ test("owner replacement preserves committed generation metadata without firing l
718
1188
  }
719
1189
  })
720
1190
 
1191
+ test("owner replacement excludes stopped retained releases from reserved process recovery", async () => {
1192
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-stopped-proof-"))
1193
+ const oldSocketPath = path.join(root, "old.sock")
1194
+ const newSocketPath = path.join(root, "new.sock")
1195
+ const statePath = path.join(root, "state.json")
1196
+ const configPath = path.join(root, "rollbridge.cjs")
1197
+ const v1Path = path.join(root, "v1")
1198
+ const v2Path = path.join(root, "v2")
1199
+ let owner
1200
+ let candidate
1201
+
1202
+ try {
1203
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
1204
+ await Promise.all([makeFifo(path.join(v1Path, "worker.fifo")), makeFifo(path.join(v2Path, "worker.fifo"))])
1205
+ await writeConfig(configPath, config({controlPath: oldSocketPath, extraCompanion: false, statePath}))
1206
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
1207
+ await waitForLog(owner, "control socket listening")
1208
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
1209
+ const stopped = sendControlCommand({command: {command: "stop", releaseId: "v1"}, path: oldSocketPath})
1210
+
1211
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
1212
+ await stopped
1213
+ await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath})
1214
+ const before = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
1215
+ const activeWorkerPid = releaseProcessPid(before, "v2", "worker")
1216
+ const retained = /** @type {{releaseId: string, state: string}[]} */ (before.releases)
1217
+
1218
+ assert.equal(retained.find(({releaseId}) => releaseId === "v1")?.state, "stopped")
1219
+ const persisted = JSON.parse(await fs.readFile(statePath, "utf8"))
1220
+ const guardian = new GuardianClient(persisted.recovery.guardian)
1221
+
1222
+ await guardian.connect()
1223
+ assert.ok((await guardian.inventory()).some(({key}) => key === "release:v1:worker"), "stopped release registration remains in authenticated guardian inventory")
1224
+ guardian.disconnect()
1225
+ await writeConfig(configPath, config({controlPath: newSocketPath, extraCompanion: true, statePath}))
1226
+ candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
1227
+ const output = await collectUntilExitOrLog(candidate, "owner replacement committed")
1228
+
1229
+ assert.equal(output.message, "owner replacement committed", output.output)
1230
+ const recovered = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
1231
+
1232
+ assert.equal(recovered.activeReleaseId, "v2")
1233
+ assert.equal(releaseProcessPid(recovered, "v2", "worker"), activeWorkerPid)
1234
+ assert.equal(/** @type {{releaseId: string}[]} */ (recovered.releases).some(({releaseId}) => releaseId === "v1"), false)
1235
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
1236
+
1237
+ await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
1238
+ await shutdown
1239
+ } finally {
1240
+ for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
1241
+ await stopGuardian(statePath)
1242
+ await fs.rm(root, {force: true, recursive: true})
1243
+ }
1244
+ })
1245
+
1246
+ test("pruned release connection completion closes the incumbent listener session", () => {
1247
+ const daemon = new RollbridgeDaemon({
1248
+ config: normalizeConfig(config({controlPath: "/unused/control.sock", extraCompanion: false, statePath: "/unused/state.json"})),
1249
+ logger: () => {}
1250
+ })
1251
+ let closeCount = 0
1252
+ const session = {close: () => { closeCount += 1 }}
1253
+ const controlSession = /** @type {Awaited<ReturnType<typeof openControlSession>>} */ (session)
1254
+
1255
+ daemon.releases = /** @type {Map<string, import("../src/release-group.js").default>} */ (new Map([
1256
+ ["active", /** @type {import("../src/release-group.js").default} */ ({hasTransferredConnections: () => false})]
1257
+ ]))
1258
+ daemon.incumbentListenerControl = controlSession
1259
+ daemon.handleIncumbentListenerEvent({connections: {http: 0, websocket: 0}, event: "owner-connection-state", releaseId: "pruned"}, session)
1260
+
1261
+ assert.equal(closeCount, 1)
1262
+ assert.equal(daemon.incumbentListenerControl, undefined)
1263
+
1264
+ daemon.incumbentListenerControl = controlSession
1265
+ assert.throws(
1266
+ () => daemon.handleIncumbentListenerEvent({connections: {http: 1, websocket: 0}, event: "owner-connection-state", releaseId: "pruned"}, session),
1267
+ /unknown release pruned/
1268
+ )
1269
+ assert.equal(closeCount, 1)
1270
+ assert.equal(daemon.incumbentListenerControl, session)
1271
+ })
1272
+
721
1273
  test("owner replacement preserves a failed generation transition without retrying its hook", async () => {
722
1274
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-failed-generation-"))
723
1275
  const oldSocketPath = path.join(root, "old.sock")
@@ -970,6 +1522,32 @@ async function runEnsureDaemon({configPath, daemonPidPath, logPath, packagePath,
970
1522
  ])
971
1523
  }
972
1524
 
1525
+ /**
1526
+ * Starts an authenticated protocol proxy that exposes the exact partial guardian surface.
1527
+ * @param {{backendPath: string, mode: string, socketPath: string, token: string}} options - Fixture identity.
1528
+ * @returns {Promise<import("node:child_process").ChildProcess>} Ready guardian proxy.
1529
+ */
1530
+ async function startPartialGuardian({backendPath, mode, socketPath, token}) {
1531
+ const child = spawn(process.execPath, [partialGuardianPath, socketPath, backendPath, mode], {
1532
+ stdio: ["ignore", "ignore", "ignore", "ipc"]
1533
+ })
1534
+
1535
+ await new Promise((resolve, reject) => {
1536
+ child.once("error", reject)
1537
+ child.once("exit", (code) => reject(new Error(`Partial guardian exited before readiness with status ${code}`)))
1538
+ child.once("message", (message) => {
1539
+ if (message && typeof message === "object" && "error" in message) reject(new Error(String(message.error)))
1540
+ else resolve(undefined)
1541
+ })
1542
+ child.send({token}, (error) => {
1543
+ if (error) reject(error)
1544
+ })
1545
+ })
1546
+ if (child.connected) await once(child, "disconnect")
1547
+ assert.ok(child.pid)
1548
+ return child
1549
+ }
1550
+
973
1551
  /**
974
1552
  * @param {net.Server} server - Unix server.
975
1553
  * @param {string} socketPath - Unix socket path.
@@ -1013,6 +1591,27 @@ function releaseProcessPid(status, releaseId, processId) {
1013
1591
  return pid
1014
1592
  }
1015
1593
 
1594
+ /**
1595
+ * @param {Record<string, import("../src/json.js").JsonValue>} status - Daemon status.
1596
+ * @param {string} releaseId - Release identity.
1597
+ * @returns {string} Release lifecycle state.
1598
+ */
1599
+ function releaseState(status, releaseId) {
1600
+ const releases = /** @type {{releaseId: string, state: string}[]} */ (status.releases)
1601
+ const state = releases.find((release) => release.releaseId === releaseId)?.state
1602
+
1603
+ if (!state) throw new Error(`Missing release state for ${releaseId}`)
1604
+ return state
1605
+ }
1606
+
1607
+ /** @returns {{promise: Promise<void>, resolve: (value: void) => void}} Controllable event barrier. */
1608
+ function deferred() {
1609
+ let resolve = /** @type {(value: void) => void} */ (() => {})
1610
+ const promise = new Promise((promiseResolve) => { resolve = promiseResolve })
1611
+
1612
+ return {promise, resolve}
1613
+ }
1614
+
1016
1615
  /**
1017
1616
  * @param {string} command - Executable.
1018
1617
  * @param {string[]} args - Arguments.