rollbridge 0.1.38 → 0.1.40

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.
@@ -13,6 +13,7 @@ import {normalizeConfig} from "../src/config.js"
13
13
  import {sendControlCommand} from "../src/control-client.js"
14
14
  import RollbridgeDaemon from "../src/daemon.js"
15
15
  import GuardianClient from "../src/guardian-client.js"
16
+ import {isProcessRunning, waitForProcessExit} from "./support/process.js"
16
17
 
17
18
  const currentDir = path.dirname(fileURLToPath(import.meta.url))
18
19
  const binPath = path.join(currentDir, "..", "bin", "rollbridge")
@@ -20,7 +21,7 @@ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
20
21
  const serviceAppPath = path.join(currentDir, "fixtures", "service-app.js")
21
22
 
22
23
  /** @typedef {import("../src/daemon.js").DaemonStatus} DaemonStatus */
23
- /** @typedef {DaemonStatus & {recovery: {configDigest: string}, singletonReleaseIds?: Record<string, string>}} RecoveryState */
24
+ /** @typedef {DaemonStatus & {recovery: {configDigest: string}, serviceReleaseIds?: Record<string, string>, singletonReleaseIds?: Record<string, string>}} RecoveryState */
24
25
 
25
26
  test("external owner retirement releases guardian authority without losing its generation", async () => {
26
27
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-recovery-"))
@@ -85,7 +86,7 @@ test("external owner retirement releases guardian authority without losing its g
85
86
  await waitForFile(path.join(v1Path, "drain-started"), 1000)
86
87
  await fs.writeFile(path.join(v1Path, "drained"), "done\n")
87
88
  await waitForProcessExit(v1WorkerPid, 1000)
88
- assert.equal(isAlive(v2WorkerPid), true, "the active candidate worker must remain usable while the old generation drains")
89
+ assert.equal(isProcessRunning(v2WorkerPid), true, "the active candidate worker must remain usable while the old generation drains")
89
90
  } finally {
90
91
  await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "drained"), "done\n").catch(() => {})))
91
92
  await replacement?.shutdown().catch(() => {})
@@ -95,6 +96,327 @@ test("external owner retirement releases guardian authority without losing its g
95
96
  }
96
97
  })
97
98
 
99
+ test("exact bootstrap restores the committed generation after external owner retirement", async () => {
100
+ const fixture = await createFixture()
101
+ const config = normalizeConfig(fixture.config, fixture.configPath)
102
+ const retired = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
103
+ /** @type {RollbridgeDaemon | undefined} */
104
+ let recovered
105
+ const v1Path = await prepareRelease(fixture.root, "v1")
106
+ const v2Path = await prepareRelease(fixture.root, "v2")
107
+
108
+ try {
109
+ await retired.start()
110
+ await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
111
+ await retired.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
112
+ const committed = retired.status()
113
+ const v1WorkerPid = releaseProcessPid(committed, "v1", "worker")
114
+ const retiredPids = [
115
+ ...(committed.releases.find(({releaseId}) => releaseId === "v2")?.processes.map(({pid}) => pid) || []),
116
+ ...committed.services.map(({process}) => process.pid),
117
+ ...committed.singletons.map(({process}) => process.pid)
118
+ ].filter((pid) => typeof pid === "number")
119
+
120
+ assert.equal(committed.activeReleaseId, "v2")
121
+ assert.equal(committed.generationTransition?.phase, "committed")
122
+ await retired.retireOwner({attestation: `sha256:${"a".repeat(64)}`})
123
+ await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
124
+ await Promise.all(retiredPids.map((pid) => waitForProcessExit(pid, 3000)))
125
+
126
+ recovered = new RollbridgeDaemon({
127
+ bootstrap: {releaseId: "v2", releasePath: v2Path, revision: "v2"},
128
+ config,
129
+ configPath: fixture.configPath,
130
+ logger: () => {}
131
+ })
132
+ await recovered.start({exposeControl: false})
133
+ const recoveryOrder = /** @type {string[]} */ ([])
134
+ const recoveredCandidate = recovered.releases.get("v2")
135
+ const recoveredService = recovered.services.get("beacon")
136
+
137
+ assert.ok(recoveredCandidate && recoveredService && recovered.singletons.get("singleton"))
138
+ recovered.serviceReleaseIds.set("beacon", "v1")
139
+ assert.throws(
140
+ () => recovered?.assertCommittedBootstrapRecoveryReady(),
141
+ /service beacon belongs to retained release v1/
142
+ )
143
+ recovered.serviceReleaseIds.set("beacon", "v2")
144
+ const activateGeneration = recoveredCandidate.activateGeneration.bind(recoveredCandidate)
145
+ const startService = recoveredService.start.bind(recoveredService)
146
+ const replaceSingletons = recovered.replaceSingletons.bind(recovered)
147
+
148
+ recoveredCandidate.activateGeneration = async () => {
149
+ recoveryOrder.push("activate")
150
+ await activateGeneration()
151
+ }
152
+ recoveredService.start = async (...args) => {
153
+ assert.equal(recovered?.generationTransition?.phase, "restoring_committed")
154
+ recoveryOrder.push("service")
155
+ await startService(...args)
156
+ }
157
+ recovered.replaceSingletons = async (...args) => {
158
+ recoveryOrder.push("singleton")
159
+ await replaceSingletons(...args)
160
+ }
161
+ await recovered.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
162
+ const active = recovered.status()
163
+
164
+ assert.equal(active.activeReleaseId, "v2")
165
+ assert.equal(active.generationTransition?.phase, "committed")
166
+ assert.equal(active.releases.find(({releaseId}) => releaseId === "v1")?.state, "draining")
167
+ assert.equal(releaseProcessPid(active, "v1", "worker"), v1WorkerPid)
168
+ assert.equal(isProcessRunning(v1WorkerPid), true, "the retained previous generation must keep draining")
169
+ assert.equal(active.releases.find(({releaseId}) => releaseId === "v2")?.state, "active")
170
+ assert.ok(active.releases.find(({releaseId}) => releaseId === "v2")?.processes.every(({pid, state}) => typeof pid === "number" && state === "running"))
171
+ assert.ok(active.services.every(({process}) => typeof process.pid === "number" && process.state === "running"))
172
+ assert.ok(active.singletons.every(({process}) => typeof process.pid === "number" && process.state === "running"))
173
+ assert.deepEqual(recoveryOrder, ["service", "activate", "singleton"], "candidate activation must precede post-commit singleton completion")
174
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2", "retire:v2", "activate:v2"])
175
+ } finally {
176
+ if (recovered) {
177
+ const activeRecovery = recovered.status().activeReleaseId === "v2"
178
+ const shutdown = recovered.shutdown()
179
+
180
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n").catch(() => undefined)
181
+ if (activeRecovery) await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined)
182
+ await shutdown.catch(() => undefined)
183
+ }
184
+ retired.guardian?.disconnect()
185
+ await stopFixtureGuardian(fixture.statePath)
186
+ await fs.rm(fixture.root, {force: true, recursive: true})
187
+ }
188
+ })
189
+
190
+ test("exact bootstrap restores a committed generation after its previous release was pruned", async () => {
191
+ const fixture = await createFixture()
192
+
193
+ fixture.config.processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
194
+ .filter((processConfig) => processConfig.id !== "beacon" && processConfig.id !== "singleton")
195
+ const worker = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
196
+ .find((processConfig) => processConfig.id === "worker")
197
+
198
+ assert.ok(worker)
199
+ const workerLifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (worker.lifecycle)
200
+
201
+ workerLifecycle.drainTimeoutMs = 500
202
+ fixture.config.releaseRetention = {keep: 0, maxAgeMs: 0}
203
+ await writeConfig(fixture.configPath, fixture.config)
204
+ const config = normalizeConfig(fixture.config, fixture.configPath)
205
+ const retired = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
206
+ /** @type {RollbridgeDaemon | undefined} */
207
+ let recovered
208
+ const v1Path = await prepareRelease(fixture.root, "v1")
209
+ const v2Path = await prepareRelease(fixture.root, "v2")
210
+
211
+ try {
212
+ await retired.start()
213
+ await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
214
+ await retired.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
215
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
216
+ await waitForState(fixture.statePath, (state) => !state.releases.some(({releaseId}) => releaseId === "v1"), AbortSignal.timeout(5000))
217
+ const committed = retired.status()
218
+ const retiredPids = committed.releases.find(({releaseId}) => releaseId === "v2")?.processes
219
+ .map(({pid}) => pid)
220
+ .filter((pid) => typeof pid === "number") || []
221
+
222
+ assert.equal(committed.releases.some(({releaseId}) => releaseId === "v1"), false)
223
+ await retired.retireOwner({attestation: `sha256:${"d".repeat(64)}`})
224
+ await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
225
+ await Promise.all(retiredPids.map((pid) => waitForProcessExit(pid, 3000)))
226
+
227
+ recovered = new RollbridgeDaemon({
228
+ bootstrap: {releaseId: "v2", releasePath: v2Path, revision: "v2"},
229
+ config,
230
+ configPath: fixture.configPath,
231
+ logger: () => {}
232
+ })
233
+ await recovered.start({exposeControl: false})
234
+ await recovered.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
235
+
236
+ assert.equal(recovered.status().activeReleaseId, "v2")
237
+ assert.equal(recovered.status().generationTransition?.phase, "committed")
238
+ } finally {
239
+ if (recovered) {
240
+ const activeRecovery = recovered.status().activeReleaseId === "v2"
241
+ const shutdown = recovered.shutdown()
242
+
243
+ if (activeRecovery) await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined)
244
+ await shutdown.catch(() => undefined)
245
+ }
246
+ retired.guardian?.disconnect()
247
+ await stopFixtureGuardian(fixture.statePath)
248
+ await fs.rm(fixture.root, {force: true, recursive: true})
249
+ }
250
+ })
251
+
252
+ test("journaled committed bootstrap recovery resumes after a restart begins", async () => {
253
+ const fixture = await createFixture()
254
+ const config = normalizeConfig(fixture.config, fixture.configPath)
255
+ const retired = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
256
+ /** @type {RollbridgeDaemon | undefined} */
257
+ let interrupted
258
+ /** @type {RollbridgeDaemon | undefined} */
259
+ let recovered
260
+ const v1Path = await prepareRelease(fixture.root, "v1")
261
+ const v2Path = await prepareRelease(fixture.root, "v2")
262
+
263
+ try {
264
+ await retired.start()
265
+ await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
266
+ await retired.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
267
+ const committed = retired.status()
268
+ const v1WorkerPid = releaseProcessPid(committed, "v1", "worker")
269
+ const retiringPids = [
270
+ ...(committed.releases.find(({releaseId}) => releaseId === "v2")?.processes.map(({pid}) => pid) || []),
271
+ ...committed.services.map(({process}) => process.pid),
272
+ ...committed.singletons.map(({process}) => process.pid)
273
+ ].filter((pid) => typeof pid === "number")
274
+
275
+ await retired.retireOwner({attestation: `sha256:${"b".repeat(64)}`})
276
+ await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
277
+ await Promise.all(retiringPids.map((pid) => waitForProcessExit(pid, 3000)))
278
+
279
+ const bootstrap = {releaseId: "v2", releasePath: v2Path, revision: "v2"}
280
+ interrupted = new RollbridgeDaemon({bootstrap, config, configPath: fixture.configPath, logger: () => {}})
281
+ await interrupted.start({exposeControl: false})
282
+ interrupted.assertCommittedBootstrapRecoveryReady()
283
+ await interrupted.updateGenerationTransition("restoring_committed")
284
+ const candidate = interrupted.releases.get("v2")
285
+
286
+ assert.ok(candidate)
287
+ for (const processInstance of interrupted.services.values()) await processInstance.start("deploy")
288
+ await candidate.restartCommittedGeneration()
289
+ await interrupted.checkpointGenerationTransition()
290
+ const restarted = interrupted.status()
291
+ const restartedCandidatePids = restarted.releases.find(({releaseId}) => releaseId === "v2")?.processes.map(({pid}) => pid)
292
+ const restartedServicePids = restarted.services.map(({process}) => process.pid)
293
+
294
+ assert.equal(restarted.generationTransition?.phase, "restoring_committed")
295
+ assert.ok(restartedCandidatePids?.every((pid) => typeof pid === "number"))
296
+ await interrupted.retireCommittedOwner(undefined)
297
+ interrupted.guardian?.disconnect()
298
+
299
+ recovered = new RollbridgeDaemon({bootstrap, config, configPath: fixture.configPath, logger: () => {}})
300
+ await recovered.start({exposeControl: false})
301
+ const active = recovered.status()
302
+
303
+ assert.equal(active.activeReleaseId, "v2")
304
+ assert.equal(active.generationTransition?.phase, "committed")
305
+ assert.deepEqual(active.releases.find(({releaseId}) => releaseId === "v2")?.processes.map(({pid}) => pid), restartedCandidatePids)
306
+ assert.deepEqual(active.services.map(({process}) => process.pid), restartedServicePids)
307
+ assert.ok(active.singletons.every(({process}) => typeof process.pid === "number" && process.state === "running"))
308
+ assert.equal(releaseProcessPid(active, "v1", "worker"), v1WorkerPid)
309
+ assert.equal(isProcessRunning(v1WorkerPid), true)
310
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2", "retire:v2", "activate:v2"])
311
+ } finally {
312
+ if (recovered) {
313
+ const shutdown = recovered.shutdown()
314
+
315
+ await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)))
316
+ await shutdown.catch(() => undefined)
317
+ }
318
+ interrupted?.guardian?.disconnect()
319
+ retired.guardian?.disconnect()
320
+ await stopFixtureGuardian(fixture.statePath)
321
+ await fs.rm(fixture.root, {force: true, recursive: true})
322
+ }
323
+ })
324
+
325
+ test("committed bootstrap tuple mismatches fail closed without singletons", async () => {
326
+ const fixture = await createFixture()
327
+ fixture.config.processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
328
+ .filter((processConfig) => processConfig.id !== "singleton")
329
+ await writeConfig(fixture.configPath, fixture.config)
330
+ const config = normalizeConfig(fixture.config, fixture.configPath)
331
+ const retired = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
332
+ /** @type {RollbridgeDaemon | undefined} */
333
+ let recovered
334
+ const v1Path = await prepareRelease(fixture.root, "v1")
335
+ const v2Path = await prepareRelease(fixture.root, "v2")
336
+ const wrongPath = await prepareRelease(fixture.root, "wrong")
337
+
338
+ try {
339
+ await retired.start()
340
+ await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
341
+ await retired.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
342
+ const committed = retired.status()
343
+ const v1WorkerPid = releaseProcessPid(committed, "v1", "worker")
344
+ const retiringPids = [
345
+ ...(committed.releases.find(({releaseId}) => releaseId === "v2")?.processes.map(({pid}) => pid) || []),
346
+ ...committed.services.map(({process}) => process.pid)
347
+ ].filter((pid) => typeof pid === "number")
348
+
349
+ await retired.retireOwner({attestation: `sha256:${"c".repeat(64)}`})
350
+ await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
351
+ await Promise.all(retiringPids.map((pid) => waitForProcessExit(pid, 3000)))
352
+ recovered = new RollbridgeDaemon({
353
+ bootstrap: {releaseId: "v2", releasePath: v2Path, revision: "v2"},
354
+ config,
355
+ configPath: fixture.configPath,
356
+ logger: () => {}
357
+ })
358
+ await recovered.start({exposeControl: false})
359
+ const owner = recovered
360
+
361
+ await assert.rejects(
362
+ () => owner.deploy({releaseId: "v2", releasePath: wrongPath, revision: "v2"}),
363
+ /only the exact same release, path, revision, and config authority/u
364
+ )
365
+ await assert.rejects(
366
+ () => owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "wrong"}),
367
+ /only the exact same release, path, revision, and config authority/u
368
+ )
369
+ const changedConfig = structuredClone(fixture.config)
370
+ const jobs = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (changedConfig.processes)
371
+ .find((processConfig) => processConfig.id === "jobs")
372
+
373
+ assert.ok(jobs)
374
+ jobs.env = {.../** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs.env), MISMATCHED_AUTHORITY: "true"}
375
+ await writeConfig(fixture.configPath, changedConfig)
376
+ await assert.rejects(
377
+ () => owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"}),
378
+ /only the exact same release, path, revision, and config authority/u
379
+ )
380
+ await writeConfig(fixture.configPath, fixture.config)
381
+ await assert.rejects(
382
+ () => owner.deploy({releaseId: "wrong", releasePath: wrongPath, revision: "wrong"}),
383
+ /only the exact same release, path, revision, and config authority/u
384
+ )
385
+ const preserved = owner.status()
386
+
387
+ assert.equal(preserved.activeReleaseId, null)
388
+ assert.equal(preserved.generationTransition?.candidateReleaseId, "v2")
389
+ assert.equal(preserved.generationTransition?.phase, "committed")
390
+ assert.equal(preserved.releases.find(({releaseId}) => releaseId === "v2")?.state, "draining")
391
+ assert.equal(releaseProcessPid(preserved, "v1", "worker"), v1WorkerPid)
392
+ assert.equal(isProcessRunning(v1WorkerPid), true)
393
+ assert.equal(preserved.releases.some(({releaseId}) => releaseId === "wrong"), false)
394
+
395
+ await owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
396
+ await Promise.all([
397
+ owner.stopRelease("v2"),
398
+ fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
399
+ ])
400
+ const afterIntentionalStop = await owner.deploy({releaseId: "wrong", releasePath: wrongPath, revision: "wrong"})
401
+
402
+ assert.equal(afterIntentionalStop.activeReleaseId, "wrong")
403
+ assert.equal(owner.status().activeReleaseId, "wrong")
404
+ } finally {
405
+ if (recovered) {
406
+ const activeReleaseId = recovered.status().activeReleaseId
407
+ const shutdown = recovered.shutdown()
408
+
409
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n").catch(() => undefined)
410
+ if (activeReleaseId === "v2") await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined)
411
+ if (activeReleaseId === "wrong") await fs.writeFile(path.join(wrongPath, "worker.fifo"), "drained\n").catch(() => undefined)
412
+ await shutdown.catch(() => undefined)
413
+ }
414
+ retired.guardian?.disconnect()
415
+ await stopFixtureGuardian(fixture.statePath)
416
+ await fs.rm(fixture.root, {force: true, recursive: true})
417
+ }
418
+ })
419
+
98
420
  test("replacement owner reconstructs one active and two draining generations after abrupt daemon exit", async () => {
99
421
  const fixture = await createFixture()
100
422
  let owner = spawnDaemon(fixture.configPath)
@@ -172,6 +494,260 @@ test("replacement owner reconstructs one active and two draining generations aft
172
494
  }
173
495
  })
174
496
 
497
+ test("guardian restarts an abruptly exited daemon without replacing managed processes", async () => {
498
+ const fixture = await createFixture()
499
+ const owner = spawnDaemon(fixture.configPath)
500
+ let recoveredDaemonPid
501
+ let workerPid
502
+
503
+ try {
504
+ await waitForLog(owner, "control socket listening")
505
+ const releasePath = await prepareRelease(fixture.root, "v1")
506
+
507
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath})
508
+ const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
509
+
510
+ workerPid = releaseProcessPid(before, "v1", "worker")
511
+ const recoveredListenerLog = waitForLog(owner, "control socket listening", {allowChildExit: true})
512
+
513
+ owner.kill("SIGKILL")
514
+ await once(owner, "exit")
515
+ await recoveredListenerLog
516
+ const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
517
+
518
+ recoveredDaemonPid = recovered.daemonPid
519
+ assert.notEqual(recovered.daemonPid, before.daemonPid)
520
+ assert.equal(recovered.activeReleaseId, "v1")
521
+ assert.equal(releaseProcessPid(recovered, "v1", "worker"), workerPid)
522
+
523
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
524
+
525
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
526
+ await shutdown
527
+ } finally {
528
+ await killChild(owner)
529
+ if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
530
+ if (workerPid) {
531
+ try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact managed group already exited. */ }
532
+ }
533
+ await stopFixtureGuardian(fixture.statePath)
534
+ await fs.rm(fixture.root, {force: true, recursive: true})
535
+ }
536
+ })
537
+
538
+ test("guardian recovers a persistent service after its final active release stops", async () => {
539
+ const fixture = await createFixture()
540
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
541
+ const jobs = processes.find((processConfig) => processConfig.id === "jobs")
542
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
543
+
544
+ assert.ok(jobs && worker)
545
+ jobs.port = {from: 17000, to: 17001}
546
+ worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
547
+ fixture.config.processes = processes.filter((processConfig) => processConfig.id !== "singleton")
548
+ await writeConfig(fixture.configPath, fixture.config)
549
+ const owner = spawnDaemon(fixture.configPath)
550
+ let recoveredDaemonPid
551
+
552
+ try {
553
+ await waitForLog(owner, "control socket listening")
554
+ const releasePath = await prepareRelease(fixture.root, "v1")
555
+
556
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath})
557
+ await sendControlCommand({command: {command: "stop", releaseId: "v1"}, path: fixture.socketPath})
558
+ const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
559
+ const servicePid = before.services[0]?.process.pid
560
+
561
+ assert.equal(before.activeReleaseId, null)
562
+ assert.equal(typeof servicePid, "number")
563
+ const persisted = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(fixture.statePath, "utf8")))
564
+
565
+ assert.equal(persisted.serviceReleaseIds?.beacon, "v1")
566
+ const recoveredListenerLog = waitForLog(owner, "control socket listening", {allowChildExit: true})
567
+
568
+ owner.kill("SIGKILL")
569
+ await once(owner, "exit")
570
+ await recoveredListenerLog
571
+ const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
572
+
573
+ recoveredDaemonPid = recovered.daemonPid
574
+ assert.equal(recovered.activeReleaseId, null)
575
+ assert.equal(recovered.services[0]?.process.pid, servicePid)
576
+ assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
577
+ const nextReleasePath = await prepareRelease(fixture.root, "v2")
578
+
579
+ await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: nextReleasePath, revision: "v2"}, path: fixture.socketPath})
580
+ const finalReleasePath = await prepareRelease(fixture.root, "v3")
581
+
582
+ await sendControlCommand({command: {command: "deploy", releaseId: "v3", releasePath: finalReleasePath, revision: "v3"}, path: fixture.socketPath})
583
+ assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v3")
584
+ await sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
585
+ } finally {
586
+ await killChild(owner)
587
+ if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
588
+ await stopFixtureGuardian(fixture.statePath)
589
+ await fs.rm(fixture.root, {force: true, recursive: true})
590
+ }
591
+ })
592
+
593
+ test("owner state omits a new persistent service until its defining release is retained", async () => {
594
+ const fixture = await createFixture()
595
+ const config = normalizeConfig(fixture.config, fixture.configPath)
596
+ const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
597
+ const releasePath = await prepareRelease(fixture.root, "v1", {holdJobsBind: true})
598
+ /** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
599
+ let deploy
600
+
601
+ try {
602
+ await owner.start()
603
+ deploy = owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
604
+ await waitForFile(path.join(releasePath, "jobs.bind-waiting"), 3000)
605
+ await owner.persistState({throwOnError: true})
606
+ const persisted = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(fixture.statePath, "utf8")))
607
+ const guardianState = /** @type {Record<string, import("../src/json.js").JsonValue> | undefined} */ (await owner.guardian?.ownerState())
608
+ const guardianSnapshot = /** @type {RecoveryState | undefined} */ (guardianState?.snapshot)
609
+
610
+ assert.deepEqual(persisted.releases, [])
611
+ assert.deepEqual(persisted.services, [])
612
+ assert.deepEqual(persisted.serviceReleaseIds, {})
613
+ assert.deepEqual(guardianSnapshot?.releases, [])
614
+ assert.deepEqual(guardianSnapshot?.services, [])
615
+ assert.deepEqual(guardianState?.serviceReleaseIds, {})
616
+
617
+ await fs.writeFile(path.join(releasePath, "jobs.bind"), "ready\n")
618
+ await deploy
619
+ assert.equal(owner.status().services.find(({id}) => id === "beacon")?.process.state, "running")
620
+ assert.equal(owner.serviceReleaseIds.get("beacon"), "v1")
621
+ } finally {
622
+ await fs.writeFile(path.join(releasePath, "jobs.bind"), "ready\n").catch(() => {})
623
+ await deploy?.catch(() => {})
624
+ await Promise.all([
625
+ fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}),
626
+ owner.shutdown().catch(() => {})
627
+ ])
628
+ owner.guardian?.disconnect()
629
+ await stopFixtureGuardian(fixture.statePath)
630
+ await fs.rm(fixture.root, {force: true, recursive: true})
631
+ }
632
+ })
633
+
634
+ test("owner recovery accepts released format-2 state without journal revision or service owner metadata", async () => {
635
+ const fixture = await createFixture()
636
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
637
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
638
+
639
+ assert.ok(worker)
640
+ worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
641
+ await writeConfig(fixture.configPath, fixture.config)
642
+ const config = normalizeConfig(fixture.config, fixture.configPath)
643
+ const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
644
+ const releasePath = await prepareRelease(fixture.root, "v1")
645
+ /** @type {RollbridgeDaemon | undefined} */
646
+ let recovered
647
+
648
+ try {
649
+ await owner.start()
650
+ await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
651
+ if (owner.pendingWrite) await owner.pendingWrite
652
+ const publicState = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
653
+ const privateOwnerState = owner.transferableOwnerState()
654
+ const privateSnapshot = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (privateOwnerState.snapshot)
655
+ const privateTransition = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (privateSnapshot.generationTransition)
656
+
657
+ delete publicState.generationTransition.journalRevision
658
+ delete publicState.serviceReleaseIds
659
+ delete privateTransition.journalRevision
660
+ delete privateOwnerState.serviceReleaseIds
661
+ await owner.guardian?.publishOwnerState(privateOwnerState)
662
+ await fs.writeFile(fixture.statePath, `${JSON.stringify(publicState, null, 2)}\n`)
663
+ await owner.retireCommittedOwner(undefined)
664
+ owner.guardian?.disconnect()
665
+
666
+ recovered = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
667
+ await recovered.start()
668
+
669
+ assert.equal(recovered.status().activeReleaseId, "v1")
670
+ assert.equal(recovered.status().generationTransition?.journalRevision, undefined)
671
+ assert.equal(recovered.serviceReleaseIds.get("beacon"), "v1")
672
+ } finally {
673
+ if (recovered) await recovered.shutdown().catch(() => {})
674
+ else await owner.shutdown().catch(() => {})
675
+ owner.guardian?.disconnect()
676
+ await stopFixtureGuardian(fixture.statePath)
677
+ await fs.rm(fixture.root, {force: true, recursive: true})
678
+ }
679
+ })
680
+
681
+ test("guardian recovery becomes ready before replaying a gated generation hook", async () => {
682
+ const fixture = await createFixture()
683
+ const retirementGatePath = path.join(fixture.root, "retirement.allow")
684
+ const retirementWaitingPath = path.join(fixture.root, "retirement.waiting")
685
+ const daemonPidPath = path.join(fixture.root, "daemon.pid")
686
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
687
+ const jobs = processes.find((processConfig) => processConfig.id === "jobs")
688
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
689
+ const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs?.lifecycle)
690
+
691
+ assert.ok(jobs && worker)
692
+ jobs.gracefulStopMs = 5000
693
+ lifecycle.quietCommand = `printf 'waiting\n' >> ${JSON.stringify(retirementWaitingPath)}; while [ ! -f ${JSON.stringify(retirementGatePath)} ]; do sleep 0.02; done; printf 'retire:%s\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(fixture.lifecycleLogPath)}`
694
+ worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
695
+ await writeConfig(fixture.configPath, fixture.config)
696
+ const owner = spawnDaemon(fixture.configPath, undefined, {daemonPidPath, startupTimeoutMs: 3000})
697
+ const v1Path = await prepareRelease(fixture.root, "v1")
698
+ const v2Path = await prepareRelease(fixture.root, "v2")
699
+ let recoveredDaemonPid
700
+
701
+ try {
702
+ await waitForLog(owner, "control socket listening")
703
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
704
+ const interruptedDeploy = sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
705
+
706
+ void interruptedDeploy.catch(() => {})
707
+ await waitForFile(retirementWaitingPath)
708
+ await fs.rm(daemonPidPath, {force: true})
709
+ const recoveredListenerLog = waitForLog(owner, "control socket listening", {allowChildExit: true})
710
+
711
+ owner.kill("SIGKILL")
712
+ await once(owner, "exit")
713
+ await recoveredListenerLog
714
+ await waitForFile(daemonPidPath, 3000)
715
+ recoveredDaemonPid = Number((await fs.readFile(daemonPidPath, "utf8")).trim())
716
+ const recovering = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
717
+ const guardianPid = JSON.parse(await fs.readFile(fixture.statePath, "utf8")).recovery?.guardian?.pid
718
+
719
+ assert.equal(recovering.daemonPid, recoveredDaemonPid)
720
+ assert.equal(recovering.ownerRecovery?.ready, true)
721
+ assert.equal(recovering.generationTransition?.phase, "retiring_previous")
722
+ assert.equal(typeof guardianPid, "number")
723
+ await assert.rejects(
724
+ sendControlCommand({command: {command: "stop", releaseId: "v1"}, path: fixture.socketPath}),
725
+ /Another owner mutation/
726
+ )
727
+ await assert.rejects(
728
+ sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath}),
729
+ /Cannot shut down while generation transition recovery is in progress/
730
+ )
731
+ await assert.rejects(
732
+ sendControlCommand({command: {attestation: `sha256:${"a".repeat(64)}`, command: "retire-owner"}, path: fixture.socketPath}),
733
+ /Cannot retire owner while generation transition recovery is in progress/
734
+ )
735
+
736
+ process.kill(recoveredDaemonPid, "SIGTERM")
737
+ await fs.writeFile(retirementGatePath, "release retirement\n")
738
+ await waitForProcessExit(recoveredDaemonPid, 5000)
739
+ await waitForProcessExit(guardianPid, 5000)
740
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v1", "activate:v2", "retire:v2"])
741
+ } finally {
742
+ await fs.writeFile(retirementGatePath, "release retirement\n").catch(() => undefined)
743
+ await sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath}).catch(() => undefined)
744
+ await killChild(owner)
745
+ if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
746
+ await stopFixtureGuardian(fixture.statePath)
747
+ await fs.rm(fixture.root, {force: true, recursive: true})
748
+ }
749
+ })
750
+
175
751
  test("owner recovery preserves a failed generation transition without firing hooks until exact resume", async () => {
176
752
  const fixture = await createFixture({activationFailureRelease: "v2"})
177
753
  let owner = spawnDaemon(fixture.configPath)
@@ -230,10 +806,15 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
230
806
  const ambiguous = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
231
807
 
232
808
  delete ambiguous.generationTransition.error
809
+ ambiguous.generationTransition.journalRevision += 1
233
810
  await fs.writeFile(fixture.statePath, `${JSON.stringify(ambiguous, null, 2)}\n`)
234
811
  await fs.writeFile(fixture.activationGatePath, "allow\n")
235
812
  owner = spawnDaemon(fixture.configPath)
813
+ const recoverySettled = waitForLog(owner, "release generation transition recovery settled")
814
+
236
815
  await waitForLog(owner, "control socket listening")
816
+ await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed", AbortSignal.timeout(5000))
817
+ await recoverySettled
237
818
 
238
819
  const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
239
820
 
@@ -251,8 +832,14 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
251
832
  }
252
833
  })
253
834
 
254
- test("owner recovery preserves exact release definitions across a candidate config authority change", async () => {
835
+ test("owner recovery preserves complete private transition authority across a candidate config change", async () => {
255
836
  const fixture = await createFixture()
837
+ const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
838
+ const initialWorker = initialProcesses.find((processConfig) => processConfig.id === "worker")
839
+
840
+ assert.ok(initialWorker)
841
+ initialWorker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
842
+ await writeConfig(fixture.configPath, fixture.config)
256
843
  const initialConfig = normalizeConfig(fixture.config, fixture.configPath)
257
844
  const owner = new RollbridgeDaemon({config: initialConfig, configPath: fixture.configPath, logger: () => {}})
258
845
  /** @type {RollbridgeDaemon | undefined} */
@@ -281,6 +868,12 @@ test("owner recovery preserves exact release definitions across a candidate conf
281
868
 
282
869
  await owner.retireCommittedOwner(undefined)
283
870
  owner.guardian?.disconnect()
871
+ const partialPublicState = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
872
+
873
+ partialPublicState.generationTransition.phase = "committed"
874
+ partialPublicState.releases = partialPublicState.releases.filter((/** @type {{releaseId: string}} */ release) => release.releaseId !== "v2")
875
+ partialPublicState.releaseReferences = partialPublicState.releaseReferences.filter((/** @type {{releaseId: string}} */ release) => release.releaseId !== "v2")
876
+ await fs.writeFile(fixture.statePath, `${JSON.stringify(partialPublicState, null, 2)}\n`)
284
877
 
285
878
  recovered = new RollbridgeDaemon({config: normalizeConfig(changedConfig, fixture.configPath), configPath: fixture.configPath, logger: () => {}})
286
879
  await recovered.start()
@@ -289,12 +882,7 @@ test("owner recovery preserves exact release definitions across a candidate conf
289
882
  assert.equal(recovered.status().generationTransition?.phase, "committed")
290
883
  assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
291
884
  } finally {
292
- if (recovered) {
293
- const shutdown = recovered.shutdown()
294
-
295
- await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)))
296
- await shutdown.catch(() => {})
297
- }
885
+ if (recovered) await recovered.shutdown().catch(() => {})
298
886
  await stopFixtureGuardian(fixture.statePath)
299
887
  await fs.rm(fixture.root, {force: true, recursive: true})
300
888
  }
@@ -330,6 +918,7 @@ test("owner recovery reconstructs a stopped release that still owns a committed-
330
918
 
331
919
  assert.equal(pending.activeReleaseId, "v2")
332
920
  assert.equal(pending.generationTransition?.phase, "committed_pending")
921
+ assert.equal(pending.serviceReleaseIds?.beacon, "v2")
333
922
  assert.equal(pending.singletonReleaseIds?.singleton, "v1")
334
923
  const stoppedRelease = pending.releases.find((release) => release.releaseId === "v1" && release.state === "stopped")
335
924
 
@@ -369,6 +958,9 @@ test("owner recovery reconstructs a stopped release that still owns a committed-
369
958
 
370
959
  test("owner recovery uses the owning release singleton definition during a committed-pending config change", async () => {
371
960
  const fixture = await createFixture()
961
+ const fixtureProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
962
+
963
+ fixture.config.processes = fixtureProcesses.filter((processConfig) => processConfig.id !== "beacon")
372
964
  const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
373
965
  const initialSingleton = initialProcesses.find((processConfig) => processConfig.id === "singleton")
374
966
 
@@ -435,6 +1027,67 @@ test("owner recovery uses the owning release singleton definition during a commi
435
1027
  }
436
1028
  })
437
1029
 
1030
+ test("owner recovery retains a stopped previous release until committed-pending singleton work completes", async () => {
1031
+ const fixture = await createFixture()
1032
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
1033
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
1034
+
1035
+ assert.ok(worker)
1036
+ worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
1037
+ await writeConfig(fixture.configPath, fixture.config)
1038
+ const config = normalizeConfig(fixture.config, fixture.configPath)
1039
+ const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
1040
+ const replaceSingletons = owner.replaceSingletons.bind(owner)
1041
+ /** @type {() => void} */
1042
+ let continueReplacement = () => {}
1043
+ /** @type {() => void} */
1044
+ let markReplacementComplete = () => {}
1045
+ const replacementGate = new Promise((resolve) => { continueReplacement = () => resolve(undefined) })
1046
+ const replacementComplete = new Promise((resolve) => { markReplacementComplete = () => resolve(undefined) })
1047
+ const v1Path = await prepareRelease(fixture.root, "v1")
1048
+ const v2Path = await prepareRelease(fixture.root, "v2")
1049
+ /** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
1050
+ let deployPromise
1051
+ /** @type {RollbridgeDaemon | undefined} */
1052
+ let recovered
1053
+
1054
+ owner.replaceSingletons = async (release) => {
1055
+ await replaceSingletons(release)
1056
+ if (release.releaseId === "v2") {
1057
+ markReplacementComplete()
1058
+ await replacementGate
1059
+ }
1060
+ }
1061
+
1062
+ try {
1063
+ await owner.start()
1064
+ await owner.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
1065
+ deployPromise = owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
1066
+ void deployPromise.catch(() => {})
1067
+ await replacementComplete
1068
+ const pending = await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed_pending" && state.releases?.some((release) => release.releaseId === "v1" && release.state === "stopped"), AbortSignal.timeout(5000))
1069
+
1070
+ assert.deepEqual(pending.releaseReferences.map((reference) => reference.releaseId), ["v1", "v2"])
1071
+ assert.equal(pending.singletonReleaseIds?.singleton, "v2")
1072
+ await owner.retireCommittedOwner(undefined)
1073
+ owner.guardian?.disconnect()
1074
+
1075
+ recovered = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
1076
+ await recovered.start()
1077
+
1078
+ assert.equal(recovered.status().generationTransition?.phase, "committed")
1079
+ assert.equal(recovered.status().activeReleaseId, "v2")
1080
+ } finally {
1081
+ continueReplacement()
1082
+ await deployPromise?.catch(() => {})
1083
+ if (recovered) await recovered.shutdown().catch(() => {})
1084
+ else await owner.shutdown().catch(() => {})
1085
+ owner.guardian?.disconnect()
1086
+ await stopFixtureGuardian(fixture.statePath)
1087
+ await fs.rm(fixture.root, {force: true, recursive: true})
1088
+ }
1089
+ })
1090
+
438
1091
  test("owner recovery replays ambiguous retirement with the previous release's exact definition", async () => {
439
1092
  const fixture = await createFixture()
440
1093
  const retirementGatePath = path.join(fixture.root, "retirement.allow")
@@ -469,8 +1122,11 @@ test("owner recovery replays ambiguous retirement with the previous release's ex
469
1122
  await fs.writeFile(retirementGatePath, "release retirement\n")
470
1123
 
471
1124
  owner = spawnDaemon(fixture.configPath)
1125
+ const recoverySettled = waitForLog(owner, "release generation transition recovery settled")
472
1126
 
473
1127
  await waitForLog(owner, "control socket listening")
1128
+ await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed", AbortSignal.timeout(5000))
1129
+ await recoverySettled
474
1130
  assert.equal((await fs.readFile(retirementWaitingPath, "utf8")).trim().split("\n").length, 2, "ambiguous retirement must replay exactly once")
475
1131
  assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v1", "activate:v2"])
476
1132
  assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2")
@@ -507,7 +1163,7 @@ test("owner recovery rejects config identity mismatch without changing the valid
507
1163
  const rejected = await runDaemon(fixture.configPath)
508
1164
 
509
1165
  assert.notEqual(rejected.code, 0)
510
- assert.match(rejected.output, /config identity does not match/)
1166
+ assert.match(rejected.output, /authority does not match/)
511
1167
  assert.equal(await fs.readFile(fixture.statePath, "utf8"), validState)
512
1168
 
513
1169
  await writeConfig(fixture.configPath, fixture.config)
@@ -566,9 +1222,10 @@ test("failed recovery bootstrap keeps the reconstructed active generation servin
566
1222
  }
567
1223
  })
568
1224
 
569
- test("owner recovery fails closed on a partial snapshot and preserves it for repair", async () => {
1225
+ test("owner recovery repairs a partial public snapshot from committed guardian state", async () => {
570
1226
  const fixture = await createFixture()
571
- let owner = spawnDaemon(fixture.configPath)
1227
+ const owner = spawnDaemon(fixture.configPath)
1228
+ let recoveredDaemonPid
572
1229
  let workerPid
573
1230
 
574
1231
  try {
@@ -585,21 +1242,24 @@ test("owner recovery fails closed on a partial snapshot and preserves it for rep
585
1242
  const partialState = {...validState, releases: []}
586
1243
  await fs.writeFile(fixture.statePath, `${JSON.stringify(partialState, null, 2)}\n`)
587
1244
 
588
- const rejected = await runDaemon(fixture.configPath)
1245
+ const repairedState = await waitForState(
1246
+ fixture.statePath,
1247
+ (state) => state.daemonPid !== validState.daemonPid && state.releases.some((release) => release.releaseId === "v1"),
1248
+ AbortSignal.timeout(5000)
1249
+ )
1250
+ recoveredDaemonPid = repairedState.daemonPid
1251
+ const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
589
1252
 
590
- assert.notEqual(rejected.code, 0)
591
- assert.match(rejected.output, /does not contain active release v1/)
592
- assert.deepEqual(JSON.parse(await fs.readFile(fixture.statePath, "utf8")), partialState)
1253
+ assert.equal(recovered.activeReleaseId, "v1")
1254
+ assert.equal(releaseProcessPid(recovered, "v1", "worker"), workerPid)
1255
+ assert.notDeepEqual(JSON.parse(await fs.readFile(fixture.statePath, "utf8")), partialState)
593
1256
 
594
- await fs.writeFile(fixture.statePath, `${JSON.stringify(validState, null, 2)}\n`)
595
- owner = spawnDaemon(fixture.configPath)
596
- await waitForLog(owner, "control socket listening")
597
1257
  const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
598
1258
  await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
599
1259
  await shutdown
600
- await once(owner, "exit")
601
1260
  } finally {
602
1261
  await killChild(owner)
1262
+ if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
603
1263
  if (workerPid) {
604
1264
  try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact group already exited. */ }
605
1265
  }
@@ -761,6 +1421,75 @@ test("deploy rejects a live ownerRecovery mode change", async () => {
761
1421
  }
762
1422
  })
763
1423
 
1424
+ test("deploy rejects a live activation lifecycle mode change", async () => {
1425
+ const fixture = await createFixture()
1426
+ const owner = spawnDaemon(fixture.configPath)
1427
+ const v1Path = await prepareRelease(fixture.root, "v1")
1428
+ const v2Path = await prepareRelease(fixture.root, "v2")
1429
+
1430
+ try {
1431
+ await waitForLog(owner, "control socket listening")
1432
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
1433
+ const changedConfig = structuredClone(fixture.config)
1434
+ const jobs = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (changedConfig.processes).find((processConfig) => processConfig.id === "jobs")
1435
+ const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs?.lifecycle)
1436
+
1437
+ delete lifecycle.activateCommand
1438
+ await writeConfig(fixture.configPath, changedConfig)
1439
+ await assert.rejects(
1440
+ sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
1441
+ /lifecycle\.activateCommand.*cannot be applied live/
1442
+ )
1443
+
1444
+ await writeConfig(fixture.configPath, fixture.config)
1445
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
1446
+
1447
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
1448
+ await shutdown
1449
+ } finally {
1450
+ await killChild(owner)
1451
+ await stopFixtureGuardian(fixture.statePath)
1452
+ await fs.rm(fixture.root, {force: true, recursive: true})
1453
+ }
1454
+ })
1455
+
1456
+ test("public state does not advance when private guardian publication fails", async () => {
1457
+ const fixture = await createFixture()
1458
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
1459
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
1460
+
1461
+ assert.ok(worker)
1462
+ worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
1463
+ await writeConfig(fixture.configPath, fixture.config)
1464
+ const config = normalizeConfig(fixture.config, fixture.configPath)
1465
+ const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
1466
+ const releasePath = await prepareRelease(fixture.root, "v1")
1467
+
1468
+ try {
1469
+ await owner.start()
1470
+ await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
1471
+ if (owner.pendingWrite) await owner.pendingWrite
1472
+ const before = await fs.readFile(fixture.statePath, "utf8")
1473
+ const publishOwnerState = owner.publishOwnerState.bind(owner)
1474
+
1475
+ owner.ownerReady = true
1476
+ owner.publishOwnerState = async () => { throw new Error("injected guardian publication failure") }
1477
+ const write = owner.persistState({throwOnError: true})
1478
+
1479
+ assert.ok(write)
1480
+ await assert.rejects(write, /injected guardian publication failure/)
1481
+ assert.equal(await fs.readFile(fixture.statePath, "utf8"), before)
1482
+ owner.publishOwnerState = publishOwnerState
1483
+ await owner.persistState({throwOnError: true})
1484
+ await owner.shutdown()
1485
+ } finally {
1486
+ owner.publishOwnerState = RollbridgeDaemon.prototype.publishOwnerState.bind(owner)
1487
+ await owner.shutdown().catch(() => {})
1488
+ await stopFixtureGuardian(fixture.statePath)
1489
+ await fs.rm(fixture.root, {force: true, recursive: true})
1490
+ }
1491
+ })
1492
+
764
1493
  test("replacement removes only guardian-owned candidate inventory left before deploy commit", async () => {
765
1494
  const fixture = await createFixture()
766
1495
  let owner = spawnDaemon(fixture.configPath)
@@ -803,7 +1532,7 @@ test("replacement removes only guardian-owned candidate inventory left before de
803
1532
 
804
1533
  assert.equal(recovered.activeReleaseId, "v1")
805
1534
  assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
806
- assert.equal(isAlive(candidatePid), false, "uncommitted candidate must be stopped before replacement becomes healthy")
1535
+ assert.equal(isProcessRunning(candidatePid), false, "uncommitted candidate must be stopped before replacement becomes healthy")
807
1536
 
808
1537
  await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
809
1538
  assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2", "removed candidate keys must be reusable by a later valid deploy")
@@ -814,7 +1543,7 @@ test("replacement removes only guardian-owned candidate inventory left before de
814
1543
  await once(owner, "exit")
815
1544
  } finally {
816
1545
  await killChild(owner)
817
- if (candidatePid && isAlive(candidatePid)) {
1546
+ if (candidatePid && isProcessRunning(candidatePid)) {
818
1547
  try { process.kill(-candidatePid, "SIGKILL") } catch (_error) { /* Exact candidate group already exited. */ }
819
1548
  }
820
1549
  await stopFixtureGuardian(fixture.statePath)
@@ -822,6 +1551,47 @@ test("replacement removes only guardian-owned candidate inventory left before de
822
1551
  }
823
1552
  })
824
1553
 
1554
+ test("ensure-daemon replaces a same-authority owner whose control socket disappeared", async () => {
1555
+ const fixture = await createFixture()
1556
+ const runtimePath = path.join(fixture.root, "runtime")
1557
+ const daemonLogPath = path.join(fixture.root, "same-authority-replacement.log")
1558
+ const daemonPidPath = path.join(fixture.root, "same-authority-replacement.pid")
1559
+ const v1Path = await prepareRelease(fixture.root, "v1")
1560
+ const ensureArgs = [
1561
+ "ensure-daemon", "--config", fixture.configPath,
1562
+ "--daemon-log-path", daemonLogPath,
1563
+ "--daemon-pid-path", daemonPidPath,
1564
+ "--daemon-runtime-path", runtimePath,
1565
+ "--daemon-start-timeout-ms", "5000"
1566
+ ]
1567
+
1568
+ try {
1569
+ const first = await runCli(ensureArgs)
1570
+
1571
+ assert.equal(first.code, 0, first.output)
1572
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
1573
+ const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
1574
+
1575
+ await fs.rm(fixture.socketPath, {force: true})
1576
+ const replacement = await runCli(ensureArgs)
1577
+
1578
+ assert.equal(replacement.code, 0, `${replacement.output}\n${await fs.readFile(daemonLogPath, "utf8")}`)
1579
+ const after = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
1580
+
1581
+ assert.equal(after.activeReleaseId, "v1")
1582
+ assert.notEqual(after.daemonPid, before.daemonPid)
1583
+ await waitForProcessExit(before.daemonPid)
1584
+
1585
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
1586
+
1587
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
1588
+ await shutdown
1589
+ } finally {
1590
+ await stopFixtureGuardian(fixture.statePath)
1591
+ await fs.rm(fixture.root, {force: true, recursive: true})
1592
+ }
1593
+ })
1594
+
825
1595
  test("ensure-daemon atomically replaces an incompatible owner without losing retained generations", async () => {
826
1596
  const fixture = await createFixture()
827
1597
  const oldControlPath = fixture.socketPath
@@ -884,6 +1654,16 @@ test("ensure-daemon atomically replaces an incompatible owner without losing ret
884
1654
  assert.equal(after.services[0]?.process.pid, before.services[0]?.process.pid)
885
1655
  assert.equal(after.singletons[0]?.process.pid, before.singletons[0]?.process.pid)
886
1656
  assert.equal(retainedConnectionClosed, false, "listener-owned WebSocket must remain supervised across replacement")
1657
+ assert.ok(after.daemonPid)
1658
+ await fs.writeFile(daemonLogPath, "")
1659
+ process.kill(after.daemonPid, "SIGKILL")
1660
+ const restartedState = await waitForState(fixture.statePath, (state) => state.daemonPid !== after.daemonPid, AbortSignal.timeout(5000))
1661
+ const restarted = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: newControlPath}))
1662
+
1663
+ assert.equal(restarted.daemonPid, restartedState.daemonPid)
1664
+ assert.equal(Number((await fs.readFile(daemonPidPath, "utf8")).trim()), restarted.daemonPid)
1665
+ assert.deepEqual(restarted.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)), before.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)))
1666
+ assert.match(await fs.readFile(daemonLogPath, "utf8"), /owner state recovered/)
887
1667
 
888
1668
  const v3Path = await prepareRelease(fixture.root, "v3")
889
1669
  await sendControlCommand({command: {command: "deploy", releaseId: "v3", releasePath: v3Path, revision: "v3"}, path: newControlPath})
@@ -1127,10 +1907,11 @@ async function stopFixtureGuardian(statePath) {
1127
1907
  /**
1128
1908
  * @param {string} statePath - State path.
1129
1909
  * @param {(state: RecoveryState) => boolean} predicate - Completion predicate.
1910
+ * @param {AbortSignal} [signal] - Optional deadline signal.
1130
1911
  * @returns {Promise<RecoveryState>} Matching state.
1131
1912
  */
1132
- async function waitForState(statePath, predicate) {
1133
- const watcher = fs.watch(path.dirname(statePath))
1913
+ async function waitForState(statePath, predicate, signal) {
1914
+ const watcher = fs.watch(path.dirname(statePath), {signal})
1134
1915
 
1135
1916
  try {
1136
1917
  const initial = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(statePath, "utf8")))
@@ -1184,17 +1965,6 @@ async function waitForFile(filePath, timeoutMs) {
1184
1965
  }
1185
1966
  }
1186
1967
 
1187
- /**
1188
- * @param {number} pid - Exact fixture process.
1189
- * @param {number} timeoutMs - Bounded exit wait.
1190
- */
1191
- async function waitForProcessExit(pid, timeoutMs) {
1192
- const deadline = Date.now() + timeoutMs
1193
-
1194
- while (isAlive(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10))
1195
- assert.equal(isAlive(pid), false, `process ${pid} did not exit within ${timeoutMs}ms`)
1196
- }
1197
-
1198
1968
  /**
1199
1969
  * Opens a live WebSocket through the fixture proxy.
1200
1970
  * @param {number} port - Proxy port.
@@ -1232,29 +2002,18 @@ function releaseProcessPid(status, releaseId, processId) {
1232
2002
  return pid
1233
2003
  }
1234
2004
 
1235
- /**
1236
- * @param {number} pid - Exact fixture pid.
1237
- * @returns {boolean} Whether the exact fixture process is alive.
1238
- */
1239
- function isAlive(pid) {
1240
- try {
1241
- process.kill(pid, 0)
1242
- return true
1243
- } catch (error) {
1244
- if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false
1245
- throw error
1246
- }
1247
- }
1248
-
1249
2005
  /**
1250
2006
  * @param {string} configPath - Config path.
1251
2007
  * @param {{releaseId: string, releasePath: string, revision: string}} [bootstrap] - Optional bootstrap tuple.
2008
+ * @param {{daemonPidPath?: string, startupTimeoutMs?: number}} [options] - Recovery command options.
1252
2009
  * @returns {import("node:child_process").ChildProcess} Daemon process.
1253
2010
  */
1254
- function spawnDaemon(configPath, bootstrap) {
2011
+ function spawnDaemon(configPath, bootstrap, options = {}) {
1255
2012
  const args = [binPath, "daemon", "--config", configPath]
1256
2013
 
1257
2014
  if (bootstrap) args.push("--release-id", bootstrap.releaseId, "--release-path", bootstrap.releasePath, "--revision", bootstrap.revision)
2015
+ if (options.daemonPidPath) args.push("--guardian-daemon-pid-path", options.daemonPidPath)
2016
+ if (options.startupTimeoutMs) args.push("--guardian-daemon-start-timeout-ms", String(options.startupTimeoutMs))
1258
2017
  return spawn(process.execPath, args, {stdio: ["ignore", "pipe", "pipe"]})
1259
2018
  }
1260
2019
 
@@ -1301,14 +2060,16 @@ async function runCli(args) {
1301
2060
  /**
1302
2061
  * @param {import("node:child_process").ChildProcess} child - Daemon child.
1303
2062
  * @param {string} message - Structured log message.
2063
+ * @param {{allowChildExit?: boolean}} [options] - Whether inherited descriptors may outlive the original child.
1304
2064
  */
1305
- async function waitForLog(child, message) {
2065
+ async function waitForLog(child, message, {allowChildExit = false} = {}) {
1306
2066
  assert.ok(child.stdout)
1307
2067
  child.stdout.setEncoding("utf8")
1308
2068
 
1309
2069
  await new Promise((resolve, reject) => {
1310
2070
  let buffer = ""
1311
2071
  let stderr = ""
2072
+ const timer = setTimeout(() => finish(new Error(`Timed out waiting for daemon log ${message}: ${stderr.trim()}`)), 5000)
1312
2073
  const onErrorData = (/** @type {string} */ chunk) => { stderr += chunk }
1313
2074
  const onExit = () => finish(new Error(`Daemon exited before logging ${message}: ${stderr.trim()}`))
1314
2075
  /** @param {string} chunk - Output chunk. */
@@ -1326,6 +2087,7 @@ async function waitForLog(child, message) {
1326
2087
  }
1327
2088
  /** @param {Error} [error] - Failure. */
1328
2089
  const finish = (error) => {
2090
+ clearTimeout(timer)
1329
2091
  child.off("exit", onExit)
1330
2092
  child.stdout?.off("data", onData)
1331
2093
  child.stderr?.off("data", onErrorData)
@@ -1333,7 +2095,7 @@ async function waitForLog(child, message) {
1333
2095
  else resolve(undefined)
1334
2096
  }
1335
2097
 
1336
- child.once("exit", onExit)
2098
+ if (!allowChildExit) child.once("exit", onExit)
1337
2099
  child.stdout?.on("data", onData)
1338
2100
  child.stderr?.setEncoding("utf8").on("data", onErrorData)
1339
2101
  })