rollbridge 0.1.30 → 0.1.32

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.
@@ -66,6 +66,21 @@ test("keeps every output line when fewer than the retention limit are produced",
66
66
  assert.deepEqual(logs.map((entry) => entry.line), ["one", "two"])
67
67
  })
68
68
 
69
+ test("emits each output line after retaining it", () => {
70
+ const managed = buildProcess(50)
71
+ let observed
72
+
73
+ managed.once("log", (entry) => {
74
+ observed = {entry, retained: managed.status().logs}
75
+ })
76
+ managed.appendLog("stdout", "ready\n")
77
+
78
+ assert.deepEqual(observed, {
79
+ entry: managed.status().logs[0],
80
+ retained: managed.status().logs
81
+ })
82
+ })
83
+
69
84
  test("reports zeroed restart and uptime fields before the process starts", () => {
70
85
  const status = buildProcess(50).status()
71
86
 
@@ -385,6 +400,24 @@ test("a hanging lifecycle hook is bounded so stop still completes", async () =>
385
400
  }
386
401
  })
387
402
 
403
+ test("activateStrict runs the configured activation command once per call and rejects failures", async () => {
404
+ const commands = /** @type {{command: string, label: string, pid: number | undefined, timeoutMs: number}[]} */ ([])
405
+ const managed = buildLongLived(() => false)
406
+
407
+ managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0}
408
+ managed.pid = 4321
409
+ managed.runHook = async (command, timeoutMs, label, pid) => {
410
+ commands.push({command, label, pid, timeoutMs})
411
+ return undefined
412
+ }
413
+
414
+ await managed.activateStrict()
415
+ assert.deepEqual(commands, [{command: "jobs activate", label: "activate command", pid: 4321, timeoutMs: 30000}])
416
+
417
+ managed.runHook = async () => new Error("activation rejected")
418
+ await assert.rejects(() => managed.activateStrict(), /activation rejected/)
419
+ })
420
+
388
421
  test("sends the configured stopSignal as the graceful stop signal", async () => {
389
422
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rollbridge-stop-signal-"))
390
423
  const readyPath = path.join(dir, "ready")
@@ -20,7 +20,7 @@ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
20
20
  const serviceAppPath = path.join(currentDir, "fixtures", "service-app.js")
21
21
 
22
22
  /** @typedef {import("../src/daemon.js").DaemonStatus} DaemonStatus */
23
- /** @typedef {DaemonStatus & {recovery: {configDigest: string}}} RecoveryState */
23
+ /** @typedef {DaemonStatus & {recovery: {configDigest: string}, singletonReleaseIds?: Record<string, string>}} RecoveryState */
24
24
 
25
25
  test("external owner retirement releases guardian authority without losing its generation", async () => {
26
26
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-recovery-"))
@@ -172,6 +172,320 @@ test("replacement owner reconstructs one active and two draining generations aft
172
172
  }
173
173
  })
174
174
 
175
+ test("owner recovery preserves a failed generation transition without firing hooks until exact resume", async () => {
176
+ const fixture = await createFixture({activationFailureRelease: "v2"})
177
+ let owner = spawnDaemon(fixture.configPath)
178
+
179
+ try {
180
+ await waitForLog(owner, "control socket listening")
181
+ const v1Path = await prepareRelease(fixture.root, "v1")
182
+ const v2Path = await prepareRelease(fixture.root, "v2")
183
+
184
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
185
+ await assert.rejects(
186
+ sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
187
+ /activate command exited non-zero/
188
+ )
189
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
190
+
191
+ owner.kill("SIGKILL")
192
+ await once(owner, "exit")
193
+ owner = spawnDaemon(fixture.configPath)
194
+ await waitForLog(owner, "control socket listening")
195
+
196
+ const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
197
+
198
+ assert.equal(recovered.generationTransition?.phase, "activating_candidate")
199
+ assert.match(String(recovered.generationTransition?.error), /activate command exited non-zero/)
200
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"], "owner recovery alone must not retry a failed hook")
201
+
202
+ await fs.writeFile(fixture.activationGatePath, "allow\n")
203
+ await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
204
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
205
+
206
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
207
+ await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
208
+ await shutdown
209
+ } finally {
210
+ await killChild(owner)
211
+ await stopFixtureGuardian(fixture.statePath)
212
+ await fs.rm(fixture.root, {force: true, recursive: true})
213
+ }
214
+ })
215
+
216
+ test("owner recovery replays one journaled ambiguous activation by exact generation identity", async () => {
217
+ const fixture = await createFixture({activationFailureRelease: "v2"})
218
+ let owner = spawnDaemon(fixture.configPath)
219
+
220
+ try {
221
+ await waitForLog(owner, "control socket listening")
222
+ const v1Path = await prepareRelease(fixture.root, "v1")
223
+ const v2Path = await prepareRelease(fixture.root, "v2")
224
+
225
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
226
+ await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}))
227
+ owner.kill("SIGKILL")
228
+ await once(owner, "exit")
229
+
230
+ const ambiguous = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
231
+
232
+ delete ambiguous.generationTransition.error
233
+ await fs.writeFile(fixture.statePath, `${JSON.stringify(ambiguous, null, 2)}\n`)
234
+ await fs.writeFile(fixture.activationGatePath, "allow\n")
235
+ owner = spawnDaemon(fixture.configPath)
236
+ await waitForLog(owner, "control socket listening")
237
+
238
+ const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
239
+
240
+ assert.equal(recovered.activeReleaseId, "v2")
241
+ assert.equal(recovered.generationTransition?.phase, "committed")
242
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
243
+
244
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
245
+ await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
246
+ await shutdown
247
+ } finally {
248
+ await killChild(owner)
249
+ await stopFixtureGuardian(fixture.statePath)
250
+ await fs.rm(fixture.root, {force: true, recursive: true})
251
+ }
252
+ })
253
+
254
+ test("owner recovery preserves exact release definitions across a candidate config authority change", async () => {
255
+ const fixture = await createFixture()
256
+ const initialConfig = normalizeConfig(fixture.config, fixture.configPath)
257
+ const owner = new RollbridgeDaemon({config: initialConfig, configPath: fixture.configPath, logger: () => {}})
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 owner.start()
265
+ await owner.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
266
+ const changedConfig = structuredClone(fixture.config)
267
+ const processes = Array.isArray(changedConfig.processes) ? changedConfig.processes : []
268
+ const jobsValue = processes.find((processConfig) => processConfig && typeof processConfig === "object" && !Array.isArray(processConfig) && processConfig.id === "jobs")
269
+
270
+ assert.ok(jobsValue && typeof jobsValue === "object" && !Array.isArray(jobsValue))
271
+ const jobs = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobsValue)
272
+
273
+ jobs.env = {.../** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs.env), RELEASE_CONFIG_AUTHORITY: "v2"}
274
+ await writeConfig(fixture.configPath, changedConfig)
275
+
276
+ // Simulate an abrupt owner loss at the durable candidate_ready boundary, before
277
+ // retirement refreshes the previous generation's guardian definition.
278
+ owner.resumeGenerationTransition = async () => ({pausedAt: "candidate_ready"})
279
+ await owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
280
+ assert.equal(owner.status().generationTransition?.phase, "candidate_ready")
281
+
282
+ await owner.retireCommittedOwner(undefined)
283
+ owner.guardian?.disconnect()
284
+
285
+ recovered = new RollbridgeDaemon({config: normalizeConfig(changedConfig, fixture.configPath), configPath: fixture.configPath, logger: () => {}})
286
+ await recovered.start()
287
+
288
+ assert.equal(recovered.status().activeReleaseId, "v2")
289
+ assert.equal(recovered.status().generationTransition?.phase, "committed")
290
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
291
+ } 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
+ }
298
+ await stopFixtureGuardian(fixture.statePath)
299
+ await fs.rm(fixture.root, {force: true, recursive: true})
300
+ }
301
+ })
302
+
303
+ test("owner recovery reconstructs a stopped release that still owns a committed-pending singleton", async () => {
304
+ const fixture = await createFixture()
305
+
306
+ fixture.config.releaseRetention = {keep: 0, maxAgeMs: 0}
307
+ await writeConfig(fixture.configPath, fixture.config)
308
+ const config = normalizeConfig(fixture.config, fixture.configPath)
309
+ const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
310
+ const replacementPause = pauseSingletonReplacement(owner, "v2")
311
+ const capturedDrain = captureReleaseDrain(owner, "v1")
312
+ const v1Path = await prepareRelease(fixture.root, "v1")
313
+ const v2Path = await prepareRelease(fixture.root, "v2")
314
+ /** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
315
+ let deployPromise
316
+ /** @type {RollbridgeDaemon | undefined} */
317
+ let recovered
318
+
319
+ try {
320
+ await owner.start()
321
+ await owner.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
322
+ deployPromise = owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
323
+ void deployPromise.catch(() => {})
324
+ await replacementPause.started
325
+ await capturedDrain.started
326
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
327
+ await capturedDrain.completed
328
+ if (owner.pendingWrite) await owner.pendingWrite
329
+ const pending = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(fixture.statePath, "utf8")))
330
+
331
+ assert.equal(pending.activeReleaseId, "v2")
332
+ assert.equal(pending.generationTransition?.phase, "committed_pending")
333
+ assert.equal(pending.singletonReleaseIds?.singleton, "v1")
334
+ const stoppedRelease = pending.releases.find((release) => release.releaseId === "v1" && release.state === "stopped")
335
+
336
+ assert.ok(stoppedRelease)
337
+ assert.deepEqual(pending.releaseReferences.map((reference) => reference.releaseId), ["v1", "v2"])
338
+ await owner.retireCommittedOwner(undefined)
339
+ owner.guardian?.disconnect()
340
+
341
+ recovered = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
342
+ await recovered.start()
343
+
344
+ assert.equal(recovered.status().generationTransition?.phase, "committed")
345
+ assert.equal(recovered.singletonReleaseIds.get("singleton"), "v2")
346
+ assert.ok(!recovered.releases.has("v1"), "the stopped singleton owner may be pruned after replacement commits")
347
+ assert.equal(recovered.portReservations.has(stoppedRelease.ports.jobs), false)
348
+ assert.equal(recovered.portReservations.has(stoppedRelease.ports.web), false)
349
+ } finally {
350
+ replacementPause.continue()
351
+ await deployPromise?.catch(() => {})
352
+ if (recovered) {
353
+ const shutdown = recovered.shutdown()
354
+
355
+ await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined)
356
+ await shutdown.catch(() => {})
357
+ recovered.guardian?.disconnect()
358
+ } else {
359
+ const shutdown = owner.shutdown()
360
+
361
+ await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined)
362
+ await shutdown.catch(() => {})
363
+ owner.guardian?.disconnect()
364
+ }
365
+ await stopFixtureGuardian(fixture.statePath)
366
+ await fs.rm(fixture.root, {force: true, recursive: true})
367
+ }
368
+ })
369
+
370
+ test("owner recovery uses the owning release singleton definition during a committed-pending config change", async () => {
371
+ const fixture = await createFixture()
372
+ const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
373
+ const initialSingleton = initialProcesses.find((processConfig) => processConfig.id === "singleton")
374
+
375
+ assert.ok(initialSingleton)
376
+ initialSingleton.env = {SINGLETON_CONFIG_AUTHORITY: "v1"}
377
+ await writeConfig(fixture.configPath, fixture.config)
378
+ const initialConfig = normalizeConfig(fixture.config, fixture.configPath)
379
+ const owner = new RollbridgeDaemon({config: initialConfig, configPath: fixture.configPath, logger: () => {}})
380
+ const replacementPause = pauseSingletonReplacement(owner, "v2")
381
+ const v1Path = await prepareRelease(fixture.root, "v1")
382
+ const v2Path = await prepareRelease(fixture.root, "v2")
383
+ /** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
384
+ let deployPromise
385
+ /** @type {RollbridgeDaemon | undefined} */
386
+ let recovered
387
+
388
+ try {
389
+ await owner.start()
390
+ await owner.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
391
+ const changedConfig = structuredClone(fixture.config)
392
+ const changedProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (changedConfig.processes)
393
+ const changedSingleton = changedProcesses.find((processConfig) => processConfig.id === "singleton")
394
+
395
+ assert.ok(changedSingleton)
396
+ changedSingleton.env = {SINGLETON_CONFIG_AUTHORITY: "v2"}
397
+ await writeConfig(fixture.configPath, changedConfig)
398
+ deployPromise = owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
399
+ void deployPromise.catch(() => {})
400
+ await replacementPause.started
401
+ const pending = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(fixture.statePath, "utf8")))
402
+
403
+ assert.equal(pending.activeReleaseId, "v2")
404
+ assert.equal(pending.generationTransition?.phase, "committed_pending")
405
+ assert.equal(pending.singletonReleaseIds?.singleton, "v1")
406
+ await owner.retireCommittedOwner(undefined)
407
+ owner.guardian?.disconnect()
408
+
409
+ const nextConfig = normalizeConfig(changedConfig, fixture.configPath)
410
+
411
+ recovered = new RollbridgeDaemon({config: nextConfig, configPath: fixture.configPath, logger: () => {}})
412
+ await recovered.start()
413
+
414
+ assert.equal(recovered.status().generationTransition?.phase, "committed")
415
+ assert.equal(recovered.singletonReleaseIds.get("singleton"), "v2")
416
+ assert.equal(recovered.singletons.get("singleton")?.env.SINGLETON_CONFIG_AUTHORITY, "v2")
417
+ } finally {
418
+ replacementPause.continue()
419
+ await deployPromise?.catch(() => {})
420
+ if (recovered) {
421
+ const shutdown = recovered.shutdown()
422
+
423
+ await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)))
424
+ await shutdown.catch(() => {})
425
+ recovered.guardian?.disconnect()
426
+ } else {
427
+ const shutdown = owner.shutdown()
428
+
429
+ await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)))
430
+ await shutdown.catch(() => {})
431
+ owner.guardian?.disconnect()
432
+ }
433
+ await stopFixtureGuardian(fixture.statePath)
434
+ await fs.rm(fixture.root, {force: true, recursive: true})
435
+ }
436
+ })
437
+
438
+ test("owner recovery replays ambiguous retirement with the previous release's exact definition", async () => {
439
+ const fixture = await createFixture()
440
+ const retirementGatePath = path.join(fixture.root, "retirement.allow")
441
+ const retirementWaitingPath = path.join(fixture.root, "retirement.waiting")
442
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
443
+ const jobs = processes.find((processConfig) => processConfig.id === "jobs")
444
+ const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs?.lifecycle)
445
+ let owner
446
+
447
+ 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)}`
448
+ await writeConfig(fixture.configPath, fixture.config)
449
+ const v1Path = await prepareRelease(fixture.root, "v1")
450
+ const v2Path = await prepareRelease(fixture.root, "v2")
451
+
452
+ try {
453
+ owner = spawnDaemon(fixture.configPath)
454
+ await waitForLog(owner, "control socket listening")
455
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
456
+ const changedConfig = structuredClone(fixture.config)
457
+ const changedProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (changedConfig.processes)
458
+ const changedJobs = changedProcesses.find((processConfig) => processConfig.id === "jobs")
459
+
460
+ assert.ok(changedJobs)
461
+ changedJobs.env = {.../** @type {Record<string, import("../src/json.js").JsonValue>} */ (changedJobs.env), RELEASE_CONFIG_AUTHORITY: "v2"}
462
+ await writeConfig(fixture.configPath, changedConfig)
463
+ const interruptedDeploy = sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
464
+ void interruptedDeploy.catch(() => {})
465
+ await waitForFile(retirementWaitingPath)
466
+
467
+ owner.kill("SIGKILL")
468
+ await once(owner, "exit")
469
+ await fs.writeFile(retirementGatePath, "release retirement\n")
470
+
471
+ owner = spawnDaemon(fixture.configPath)
472
+
473
+ await waitForLog(owner, "control socket listening")
474
+ assert.equal((await fs.readFile(retirementWaitingPath, "utf8")).trim().split("\n").length, 2, "ambiguous retirement must replay exactly once")
475
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v1", "activate:v2"])
476
+ assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2")
477
+
478
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
479
+
480
+ await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)))
481
+ await shutdown
482
+ } finally {
483
+ await killChild(owner)
484
+ await stopFixtureGuardian(fixture.statePath)
485
+ await fs.rm(fixture.root, {force: true, recursive: true})
486
+ }
487
+ })
488
+
175
489
  test("owner recovery rejects config identity mismatch without changing the valid snapshot", async () => {
176
490
  const fixture = await createFixture()
177
491
  let owner = spawnDaemon(fixture.configPath)
@@ -427,8 +741,9 @@ test("deploy rejects a live ownerRecovery mode change", async () => {
427
741
 
428
742
  try {
429
743
  await waitForLog(owner, "control socket listening")
430
- const changedConfig = {...fixture.config}
744
+ const changedConfig = /** @type {import("../src/config.js").RollbridgeConfig} */ (structuredClone(fixture.config))
431
745
  delete changedConfig.ownerRecovery
746
+ for (const processConfig of changedConfig.processes) if (processConfig.lifecycle) delete processConfig.lifecycle.activateCommand
432
747
  await writeConfig(fixture.configPath, changedConfig)
433
748
 
434
749
  await assert.rejects(
@@ -608,13 +923,18 @@ test("ensure-daemon atomically replaces an incompatible owner without losing ret
608
923
  }
609
924
  })
610
925
 
611
- /** @returns {Promise<{config: Record<string, import("../src/json.js").JsonValue>, configPath: string, root: string, socketPath: string, statePath: string}>} Fixture paths. */
612
- async function createFixture() {
926
+ /**
927
+ * @param {{activationFailureRelease?: string}} [options] - Lifecycle fault injection.
928
+ * @returns {Promise<{activationGatePath: string, config: Record<string, import("../src/json.js").JsonValue>, configPath: string, lifecycleLogPath: string, root: string, socketPath: string, statePath: string}>} Fixture paths.
929
+ */
930
+ async function createFixture(options = {}) {
613
931
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-recovery-"))
614
932
  const socketPath = path.join(root, "rollbridge.sock")
615
933
  const configPath = path.join(root, "rollbridge.cjs")
616
934
  const statePath = path.join(root, "rollbridge.state.json")
617
935
  const serviceLogPath = path.join(root, "service.log")
936
+ const lifecycleLogPath = path.join(root, "generation.lifecycle")
937
+ const activationGatePath = path.join(root, "activation.allow")
618
938
  const config = {
619
939
  application: "owner-recovery-test",
620
940
  control: {path: socketPath},
@@ -636,7 +956,10 @@ async function createFixture() {
636
956
  ROLLBRIDGE_SERVICE_LOG: serviceLogPath
637
957
  },
638
958
  id: "jobs",
639
- lifecycle: {quietCommand: "exit 0"},
959
+ lifecycle: {
960
+ activateCommand: `${options.activationFailureRelease ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(options.activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
961
+ quietCommand: `printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
962
+ },
640
963
  policy: "service",
641
964
  port: {from: 17000, to: 17020}
642
965
  },
@@ -666,7 +989,15 @@ async function createFixture() {
666
989
 
667
990
  await writeConfig(configPath, config)
668
991
 
669
- return {config, configPath, root, socketPath, statePath}
992
+ return {activationGatePath, config, configPath, lifecycleLogPath, root, socketPath, statePath}
993
+ }
994
+
995
+ /**
996
+ * @param {string} lifecycleLogPath - Fixture lifecycle log.
997
+ * @returns {Promise<string[]>} Ordered lifecycle events.
998
+ */
999
+ async function lifecycleEvents(lifecycleLogPath) {
1000
+ return (await fs.readFile(lifecycleLogPath, "utf8")).trim().split("\n").filter(Boolean)
670
1001
  }
671
1002
 
672
1003
  /**
@@ -694,6 +1025,65 @@ async function writeConfig(configPath, config) {
694
1025
  await fs.writeFile(configPath, `module.exports = ${JSON.stringify(config, null, 2)}\n`)
695
1026
  }
696
1027
 
1028
+ /**
1029
+ * Pauses only one candidate's post-commit singleton replacement at an exact promise boundary.
1030
+ * @param {RollbridgeDaemon} daemon - Current owner.
1031
+ * @param {string} releaseId - Candidate release to pause.
1032
+ * @returns {{continue: () => void, started: Promise<void>}} Pause controls.
1033
+ */
1034
+ function pauseSingletonReplacement(daemon, releaseId) {
1035
+ const replaceSingletons = daemon.replaceSingletons.bind(daemon)
1036
+ /** @type {() => void} */
1037
+ let markStarted = () => {}
1038
+ /** @type {() => void} */
1039
+ let continueReplacement = () => {}
1040
+ const started = new Promise((resolve) => { markStarted = () => resolve(undefined) })
1041
+ const gate = new Promise((resolve) => { continueReplacement = () => resolve(undefined) })
1042
+
1043
+ daemon.replaceSingletons = async (release) => {
1044
+ if (release.releaseId === releaseId) {
1045
+ markStarted()
1046
+ await gate
1047
+ }
1048
+ await replaceSingletons(release)
1049
+ }
1050
+ return {continue: continueReplacement, started}
1051
+ }
1052
+
1053
+ /**
1054
+ * Captures one background release drain through its package-owned promise.
1055
+ * @param {RollbridgeDaemon} daemon - Current owner.
1056
+ * @param {string} releaseId - Release whose drain completion is required.
1057
+ * @returns {{completed: Promise<void>, started: Promise<void>}} Drain boundaries.
1058
+ */
1059
+ function captureReleaseDrain(daemon, releaseId) {
1060
+ const drainAndPrune = daemon.drainAndPrune.bind(daemon)
1061
+ /** @type {() => void} */
1062
+ let markStarted = () => {}
1063
+ /** @type {(error: Error | string) => void} */
1064
+ let rejectCompleted = () => {}
1065
+ /** @type {() => void} */
1066
+ let resolveCompleted = () => {}
1067
+ const started = new Promise((resolve) => { markStarted = () => resolve(undefined) })
1068
+ const completed = new Promise((resolve, reject) => {
1069
+ resolveCompleted = () => resolve(undefined)
1070
+ rejectCompleted = reject
1071
+ })
1072
+
1073
+ daemon.drainAndPrune = async (release, config) => {
1074
+ if (release.releaseId !== releaseId) return await drainAndPrune(release, config)
1075
+ markStarted()
1076
+ try {
1077
+ await drainAndPrune(release, config)
1078
+ resolveCompleted()
1079
+ } catch (error) {
1080
+ rejectCompleted(error instanceof Error ? error : String(error))
1081
+ throw error
1082
+ }
1083
+ }
1084
+ return {completed, started}
1085
+ }
1086
+
697
1087
  /**
698
1088
  * @param {string} statePath - Fixture state path.
699
1089
  * @returns {Promise<void>} Cleanup completion.