rollbridge 0.1.31 → 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.
@@ -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.
@@ -422,6 +422,100 @@ test("replacement transfers an unchanged fixed proxy listener without reusePort"
422
422
  }
423
423
  })
424
424
 
425
+ test("owner replacement preserves committed generation metadata without firing lifecycle hooks", async () => {
426
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-generation-"))
427
+ const oldSocketPath = path.join(root, "old.sock")
428
+ const newSocketPath = path.join(root, "new.sock")
429
+ const statePath = path.join(root, "state.json")
430
+ const configPath = path.join(root, "rollbridge.cjs")
431
+ const releasePath = path.join(root, "v1")
432
+ const lifecycleLogPath = path.join(root, "generation.lifecycle")
433
+ let owner
434
+ let candidate
435
+
436
+ try {
437
+ await fs.mkdir(releasePath)
438
+ await makeFifo(path.join(releasePath, "worker.fifo"))
439
+ await writeConfig(configPath, config({activationLogPath: lifecycleLogPath, controlPath: oldSocketPath, extraCompanion: false, statePath}))
440
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
441
+ await waitForLog(owner, "control socket listening")
442
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: oldSocketPath})
443
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\n")
444
+
445
+ await writeConfig(configPath, config({activationLogPath: lifecycleLogPath, controlPath: newSocketPath, extraCompanion: true, statePath}))
446
+ candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
447
+ await waitForLog(candidate, "owner replacement committed")
448
+
449
+ const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
450
+ const generationTransition = status.generationTransition
451
+
452
+ assert.equal(status.activeReleaseId, "v1")
453
+ assert.ok(generationTransition && typeof generationTransition === "object" && !Array.isArray(generationTransition))
454
+ assert.equal(generationTransition.phase, "committed")
455
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\n", "owner replacement must not reactivate an already committed generation")
456
+
457
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
458
+ await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
459
+ await shutdown
460
+ } finally {
461
+ for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
462
+ await stopGuardian(statePath)
463
+ await fs.rm(root, {force: true, recursive: true})
464
+ }
465
+ })
466
+
467
+ test("owner replacement preserves a failed generation transition without retrying its hook", async () => {
468
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-failed-generation-"))
469
+ const oldSocketPath = path.join(root, "old.sock")
470
+ const newSocketPath = path.join(root, "new.sock")
471
+ const statePath = path.join(root, "state.json")
472
+ const configPath = path.join(root, "rollbridge.cjs")
473
+ const lifecycleLogPath = path.join(root, "generation.lifecycle")
474
+ const v1Path = path.join(root, "v1")
475
+ const v2Path = path.join(root, "v2")
476
+ let owner
477
+ let candidate
478
+ const failedConfig = (/** @type {string} */ controlPath, /** @type {boolean} */ extraCompanion) => {
479
+ const raw = config({activationLogPath: lifecycleLogPath, controlPath, extraCompanion, statePath})
480
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (raw.processes)
481
+ const generationMain = processes.find((processConfig) => processConfig.id === "generation-main")
482
+ const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (generationMain?.lifecycle)
483
+
484
+ lifecycle.activateCommand = `[ "$ROLLBRIDGE_RELEASE_ID" != v2 ] || exit 24; printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
485
+ return raw
486
+ }
487
+
488
+ try {
489
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
490
+ await Promise.all([makeFifo(path.join(v1Path, "worker.fifo")), makeFifo(path.join(v2Path, "worker.fifo"))])
491
+ await writeConfig(configPath, failedConfig(oldSocketPath, false))
492
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
493
+ await waitForLog(owner, "control socket listening")
494
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
495
+ await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath}), /activate command exited non-zero/)
496
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n")
497
+
498
+ await writeConfig(configPath, failedConfig(newSocketPath, true))
499
+ candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
500
+ await waitForLog(candidate, "owner replacement committed")
501
+ const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
502
+ const generationTransition = status.generationTransition
503
+
504
+ assert.ok(generationTransition && typeof generationTransition === "object" && !Array.isArray(generationTransition))
505
+ assert.equal(generationTransition.phase, "activating_candidate")
506
+ assert.match(String(generationTransition.error), /activate command exited non-zero/)
507
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n", "replacement must preserve, not retry, the failed activation")
508
+
509
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
510
+ await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
511
+ await shutdown
512
+ } finally {
513
+ for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
514
+ await stopGuardian(statePath)
515
+ await fs.rm(root, {force: true, recursive: true})
516
+ }
517
+ })
518
+
425
519
  test("replacement publishes an unchanged control path only after incumbent retirement", async () => {
426
520
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-same-control-"))
427
521
  const socketPath = path.join(root, "rollbridge.sock")
@@ -518,10 +612,10 @@ test("prepared replacement fences incumbent mutations until abort", async () =>
518
612
  })
519
613
 
520
614
  /**
521
- * @param {{controlPath: string, extraCompanion: boolean, proxyPort?: number, statePath: string}} options - Fixture options.
615
+ * @param {{activationLogPath?: string, controlPath: string, extraCompanion: boolean, proxyPort?: number, statePath: string}} options - Fixture options.
522
616
  * @returns {Record<string, import("../src/json.js").JsonValue>} Raw fixture config.
523
617
  */
524
- function config({controlPath, extraCompanion, proxyPort = 0, statePath}) {
618
+ function config({activationLogPath, controlPath, extraCompanion, proxyPort = 0, statePath}) {
525
619
  const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`
526
620
  const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ ([
527
621
  {
@@ -534,6 +628,17 @@ function config({controlPath, extraCompanion, proxyPort = 0, statePath}) {
534
628
  {command, health: {intervalMs: 25, path: "/ping", timeoutMs: 3000}, id: "web", policy: "proxied", port: {from: 0, to: 0}}
535
629
  ])
536
630
 
631
+ if (activationLogPath) processes.unshift({
632
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
633
+ deployStrategy: "handoff",
634
+ id: "generation-main",
635
+ lifecycle: {
636
+ activateCommand: `printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(activationLogPath)}`,
637
+ quietCommand: `printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(activationLogPath)}`
638
+ },
639
+ policy: "service",
640
+ port: {from: 23000, to: 23999}
641
+ })
537
642
  if (extraCompanion) processes.splice(1, 0, {command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`, id: "metrics", policy: "companion"})
538
643
  return {
539
644
  application: "owner-replacement-test",