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.
@@ -505,6 +505,277 @@ test("candidate activation retires jobs-main with its workers without waiting fo
505
505
  }
506
506
  })
507
507
 
508
+ test("opt-in generation lifecycle retires the old generation before activating and committing the candidate", async () => {
509
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, nonBlockingDrainWorker: true, webDependsOnService: true})
510
+ const daemon = await startDaemon(fixture.config)
511
+
512
+ try {
513
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
514
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
515
+ assert.equal(daemon.status().activeReleaseId, "v1")
516
+
517
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
518
+
519
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
520
+ assert.equal(daemon.status().activeReleaseId, "v2")
521
+ assert.equal(daemon.status().generationTransition?.phase, "committed")
522
+ } finally {
523
+ await daemon.shutdown()
524
+ await fs.rm(fixture.root, {force: true, recursive: true})
525
+ }
526
+ })
527
+
528
+ test("generation commit is durable before awaited post-transition work", async () => {
529
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, includeSingleton: true, webDependsOnService: true})
530
+ const daemon = await startDaemon(fixture.config)
531
+ const replaceSingletons = daemon.replaceSingletons.bind(daemon)
532
+ /** @type {() => void} */
533
+ let markReplacementStarted = () => {}
534
+ /** @type {() => void} */
535
+ let releaseReplacement = () => {}
536
+ const replacementStarted = new Promise((resolve) => { markReplacementStarted = () => resolve(undefined) })
537
+ const replacementGate = new Promise((resolve) => { releaseReplacement = () => resolve(undefined) })
538
+ /** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
539
+ let deployPromise
540
+
541
+ try {
542
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
543
+ daemon.replaceSingletons = async (release) => {
544
+ if (release.releaseId === "v2") {
545
+ markReplacementStarted()
546
+ await replacementGate
547
+ }
548
+ await replaceSingletons(release)
549
+ }
550
+
551
+ deployPromise = daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
552
+ await replacementStarted
553
+
554
+ const persisted = /** @type {{activeReleaseId?: string, generationTransition?: {phase?: string}, singletonReleaseIds?: Record<string, string>} | undefined} */ (await readState(fixture.statePath))
555
+
556
+ assert.equal(daemon.status().activeReleaseId, "v2")
557
+ assert.equal(persisted?.activeReleaseId, "v2")
558
+ assert.equal(persisted?.generationTransition?.phase, "committed_pending")
559
+ assert.equal(persisted?.singletonReleaseIds?.["jobs-main"], "v1")
560
+
561
+ releaseReplacement()
562
+ await deployPromise
563
+ assert.equal(daemon.status().generationTransition?.phase, "committed")
564
+ } finally {
565
+ releaseReplacement()
566
+ await deployPromise?.catch(() => {})
567
+ await daemon.shutdown()
568
+ await fs.rm(fixture.root, {force: true, recursive: true})
569
+ }
570
+ })
571
+
572
+ test("exact committed retry finishes pending singleton replacement before success", async () => {
573
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, includeSingleton: true, singletonCwd: "{{releasePath}}/{{releaseId}}", webDependsOnService: true})
574
+ const daemon = await startDaemon(fixture.config)
575
+
576
+ await fs.mkdir(path.join(fixture.root, "v1"))
577
+
578
+ try {
579
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
580
+ await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /ENOENT/)
581
+
582
+ assert.equal(daemon.status().activeReleaseId, "v2", "traffic remains durably committed")
583
+ assert.equal(daemon.status().generationTransition?.phase, "committed_pending")
584
+ assert.notEqual(daemon.status().singletons[0]?.process.state, "running")
585
+
586
+ await fs.mkdir(path.join(fixture.root, "v2"))
587
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
588
+
589
+ assert.equal(daemon.status().generationTransition?.phase, "committed")
590
+ assert.equal(daemon.status().singletons[0]?.process.state, "running")
591
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
592
+ } finally {
593
+ await daemon.shutdown()
594
+ await fs.rm(fixture.root, {force: true, recursive: true})
595
+ }
596
+ })
597
+
598
+ test("first generation is not committed when its activation acknowledgement fails", async () => {
599
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: "v1", nonBlockingDrainWorker: true, webDependsOnService: true})
600
+ const daemon = await startDaemon(fixture.config)
601
+
602
+ try {
603
+ await assert.rejects(() => daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"}), /activate command exited non-zero/)
604
+ const status = daemon.status()
605
+
606
+ assert.equal(status.activeReleaseId, null)
607
+ assert.equal(status.generationTransition?.phase, "activating_candidate")
608
+ assert.deepEqual(status.releaseReferences.map((reference) => reference.releaseId), ["v1"])
609
+ } finally {
610
+ await daemon.shutdown()
611
+ await fs.rm(fixture.root, {force: true, recursive: true})
612
+ }
613
+ })
614
+
615
+ test("retirement failure retains the exact transition, blocks other deploys, and exact resume continues it", async () => {
616
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceQuietFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
617
+ const daemon = await startDaemon(fixture.config)
618
+
619
+ try {
620
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
621
+ await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /retirement quiescence failed/)
622
+
623
+ const failed = daemon.status()
624
+
625
+ assert.equal(failed.activeReleaseId, "v1")
626
+ assert.equal(failed.generationTransition?.phase, "retiring_previous")
627
+ assert.match(String(failed.generationTransition?.error), /quiet command exited non-zero/)
628
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
629
+ await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
630
+
631
+ await fs.writeFile(fixture.retirementGatePath, "allow\n")
632
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
633
+
634
+ assert.equal(daemon.status().activeReleaseId, "v2")
635
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
636
+ } finally {
637
+ await daemon.shutdown()
638
+ await fs.rm(fixture.root, {force: true, recursive: true})
639
+ }
640
+ })
641
+
642
+ test("candidate activation failure never reactivates old and exact resume activates only the candidate", async () => {
643
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
644
+ const daemon = await startDaemon(fixture.config)
645
+
646
+ try {
647
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
648
+ await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
649
+
650
+ const failed = daemon.status()
651
+
652
+ assert.equal(failed.activeReleaseId, "v1")
653
+ assert.equal(failed.generationTransition?.phase, "activating_candidate")
654
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
655
+
656
+ await fs.writeFile(fixture.activationGatePath, "allow\n")
657
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
658
+
659
+ assert.equal(daemon.status().activeReleaseId, "v2")
660
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
661
+ } finally {
662
+ await daemon.shutdown()
663
+ await fs.rm(fixture.root, {force: true, recursive: true})
664
+ }
665
+ })
666
+
667
+ test("unresolved generation transition fences stop, restart, and rollback mutations", async () => {
668
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
669
+ const daemon = await startDaemon(fixture.config)
670
+
671
+ try {
672
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
673
+ await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
674
+
675
+ await assert.rejects(() => daemon.stopRelease("v2"), /cannot stop.*generation transition.*unresolved/i)
676
+ await assert.rejects(() => daemon.restartProcesses({processId: "beacon"}), /cannot restart.*generation transition.*unresolved/i)
677
+ await assert.rejects(() => daemon.rollback({releaseId: "v2"}), /cannot rollback.*generation transition.*unresolved/i)
678
+
679
+ assert.notEqual(statusRelease(daemon, "v2").processes.find((entry) => entry.id === "web")?.state, "stopped")
680
+ await fs.writeFile(fixture.activationGatePath, "allow\n")
681
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
682
+ assert.equal(await fetchText(daemon, "/release"), "v2")
683
+ } finally {
684
+ await daemon.shutdown()
685
+ await fs.rm(fixture.root, {force: true, recursive: true})
686
+ }
687
+ })
688
+
689
+ test("active generation restores its exact lifecycle role after coordinator auto-restart", async () => {
690
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, webDependsOnService: true})
691
+ const daemon = await startDaemon(fixture.config)
692
+
693
+ try {
694
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
695
+ const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
696
+
697
+ assert.ok(coordinator?.pid)
698
+ process.kill(-coordinator.pid, "SIGKILL")
699
+ await waitFor(async () => (await lifecycleEvents(fixture.lifecycleLogPath)).length === 2 && statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")?.state === "running", 3000)
700
+
701
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "activate:v1"])
702
+ assert.equal(statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")?.state, "running")
703
+ assert.equal(await fetchText(daemon, "/release"), "v1")
704
+ } finally {
705
+ await daemon.shutdown()
706
+ await fs.rm(fixture.root, {force: true, recursive: true})
707
+ }
708
+ })
709
+
710
+ test("failed active-role restoration is loud and never reports the restarted coordinator running", async () => {
711
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: "v1", webDependsOnService: true})
712
+ const daemon = await startDaemon(fixture.config)
713
+
714
+ await fs.writeFile(fixture.activationGatePath, "allow\n")
715
+
716
+ try {
717
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
718
+ await fs.rm(fixture.activationGatePath)
719
+ const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
720
+
721
+ assert.ok(coordinator?.pid)
722
+ process.kill(-coordinator.pid, "SIGKILL")
723
+ await waitFor(() => {
724
+ const status = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
725
+
726
+ return status?.state === "failed" && status.restarts === 1
727
+ }, 3000)
728
+
729
+ const failed = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
730
+
731
+ assert.equal(failed?.lifecycleRole, "active")
732
+ assert.equal(failed?.restarts, 1, "role restoration failure must not create an internal retry loop")
733
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
734
+ } finally {
735
+ await daemon.shutdown()
736
+ await fs.rm(fixture.root, {force: true, recursive: true})
737
+ }
738
+ })
739
+
740
+ test("retired generation coordinator remains fenced after exit", async () => {
741
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, webDependsOnService: true})
742
+ const daemon = await startDaemon(fixture.config)
743
+ /** @type {WebSocket | undefined} */
744
+ let socket
745
+
746
+ try {
747
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
748
+ socket = await openWebSocket(daemon)
749
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
750
+ const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
751
+ const coordinatorProcess = daemon.releases.get("v1")?.getProcess("beacon")
752
+
753
+ assert.ok(coordinator?.pid)
754
+ assert.ok(coordinatorProcess)
755
+ assert.equal(coordinator.lifecycleRole, "retired")
756
+ assert.equal(daemon.guardian?.processes.get("release:v1:beacon"), coordinatorProcess, "retirement refresh must preserve exact guardian event routing")
757
+ const exited = once(coordinatorProcess, "exit")
758
+
759
+ process.kill(-coordinator.pid, "SIGKILL")
760
+ const [exit] = await exited
761
+ const stopped = coordinatorProcess.status()
762
+
763
+ assert.equal(exit.code, null)
764
+ assert.equal(exit.id, "beacon")
765
+ assert.equal(exit.signal, "SIGKILL")
766
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
767
+ assert.equal(stopped.lifecycleRole, "retired")
768
+ assert.equal(stopped.pid, undefined)
769
+ assert.equal(stopped.restarts, 0)
770
+ assert.equal(stopped.state, "stopped")
771
+ assert.equal(coordinatorProcess.restartTimer, undefined, "retired process must not queue a restart")
772
+ } finally {
773
+ socket?.close()
774
+ await daemon.shutdown()
775
+ await fs.rm(fixture.root, {force: true, recursive: true})
776
+ }
777
+ })
778
+
508
779
  test("multiple retired jobs generations keep distinct endpoints and live references until completion", async () => {
509
780
  const fixture = await createFixture({handoffService: true, handoffServiceQuiet: true, webDependsOnService: true})
510
781
  const daemon = await startDaemon(fixture.config)
@@ -819,7 +1090,11 @@ test("persisted daemon state excludes process commands, environment values, and
819
1090
 
820
1091
  try {
821
1092
  await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
822
- await waitFor(() => Boolean(daemon.activeRelease?.getProcess("web")?.status().logs.some((entry) => entry.line === secret)))
1093
+ const webProcess = daemon.activeRelease?.getProcess("web")
1094
+
1095
+ assert.ok(webProcess)
1096
+ await recordedLogLine(webProcess, secret)
1097
+ assert.ok(webProcess.status().logs.some((entry) => entry.line === secret), "secret output must be retained before persistence")
823
1098
 
824
1099
  daemon.persistState()
825
1100
  await waitFor(async () => (await fs.readFile(fixture.statePath, "utf8")).includes('"activeReleaseId": "v1"'))
@@ -1358,19 +1633,30 @@ test("deploy can ensure the daemon before sending the release command", async ()
1358
1633
  })
1359
1634
 
1360
1635
  /**
1361
- * @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
1362
- * @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, serviceQuietPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
1636
+ * @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceActivate?: boolean, handoffServiceActivateFailure?: boolean | string, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
1637
+ * @returns {Promise<{activationGatePath: string, config: import("../src/config.js").RollbridgeConfig, lifecycleLogPath: string, retirementGatePath: string, root: string, serviceLogPath: string, serviceQuietPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
1363
1638
  */
1364
1639
  async function createFixture(options = {}) {
1365
1640
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-test-"))
1366
1641
  const serviceLogPath = path.join(root, "service.log")
1367
1642
  const serviceQuietPath = path.join(root, "service.quiet")
1643
+ const lifecycleLogPath = path.join(root, "service.lifecycle")
1644
+ const activationGatePath = path.join(root, "activation.allow")
1645
+ const retirementGatePath = path.join(root, "retirement.allow")
1368
1646
  const singletonLogPath = path.join(root, "singleton.log")
1369
1647
  const statePath = path.join(root, "rollbridge.state.json")
1370
1648
  /** @type {Array<Record<string, import("../src/json.js").JsonValue>>} */
1371
1649
  const processes = []
1372
1650
 
1373
1651
  if (options.includeService || options.handoffService) {
1652
+ const activationFailureRelease = typeof options.handoffServiceActivateFailure === "string" ? options.handoffServiceActivateFailure : "v2"
1653
+ const lifecycle = options.handoffServiceActivate ? {
1654
+ activateCommand: `${options.handoffServiceActivateFailure ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
1655
+ quietCommand: `${options.handoffServiceQuietFailure ? `[ -f ${JSON.stringify(retirementGatePath)} ] || exit 23; ` : ""}printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1656
+ } : options.handoffServiceQuiet || options.handoffServiceQuietFailure ? {
1657
+ quietCommand: options.handoffServiceQuietFailure ? "exit 23" : `printf '%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(serviceQuietPath)}`
1658
+ } : undefined
1659
+
1374
1660
  processes.push({
1375
1661
  command: `${JSON.stringify(process.execPath)} ${JSON.stringify(serviceAppPath)} --release={{releaseId}}`,
1376
1662
  ...(options.handoffService ? {deployStrategy: "handoff"} : {}),
@@ -1378,7 +1664,7 @@ async function createFixture(options = {}) {
1378
1664
  ROLLBRIDGE_SERVICE_LOG: serviceLogPath
1379
1665
  },
1380
1666
  id: "beacon",
1381
- ...(options.handoffServiceQuiet || options.handoffServiceQuietFailure ? {lifecycle: {quietCommand: options.handoffServiceQuietFailure ? "exit 23" : `printf '%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(serviceQuietPath)}`}} : {}),
1667
+ ...(lifecycle ? {lifecycle} : {}),
1382
1668
  policy: "service",
1383
1669
  port: options.handoffService ? {from: 15000, to: 15099} : {from: 0, to: 0},
1384
1670
  restartDelayMs: 50
@@ -1463,10 +1749,18 @@ async function createFixture(options = {}) {
1463
1749
  host: options.proxyHost || "127.0.0.1",
1464
1750
  port: 0
1465
1751
  },
1466
- ...(options.persistState ? {statePath} : {})
1752
+ ...((options.persistState || options.handoffServiceActivate) ? {ownerRecovery: {reconnectGraceMs: 30000}, statePath} : {})
1467
1753
  })
1468
1754
 
1469
- return {config, root, serviceLogPath, serviceQuietPath, singletonLogPath, statePath}
1755
+ return {activationGatePath, config, lifecycleLogPath, retirementGatePath, root, serviceLogPath, serviceQuietPath, singletonLogPath, statePath}
1756
+ }
1757
+
1758
+ /**
1759
+ * @param {string} lifecycleLogPath - Fixture lifecycle event file.
1760
+ * @returns {Promise<string[]>} Ordered lifecycle events.
1761
+ */
1762
+ async function lifecycleEvents(lifecycleLogPath) {
1763
+ return (await fs.readFile(lifecycleLogPath, "utf8")).trim().split("\n").filter(Boolean)
1470
1764
  }
1471
1765
 
1472
1766
  /**
@@ -1604,6 +1898,39 @@ async function waitFor(callback, timeoutMs = 3000) {
1604
1898
  throw new Error("Timed out waiting for condition")
1605
1899
  }
1606
1900
 
1901
+ /**
1902
+ * Resolves from retained output or the exact event that records it, and rejects if the
1903
+ * supervised process exits first.
1904
+ * @param {import("../src/managed-process.js").default} processInstance - Exact managed process.
1905
+ * @param {string} line - Complete output line to observe.
1906
+ * @returns {Promise<import("../src/managed-process.js").ManagedProcessLog>} Recorded log entry.
1907
+ */
1908
+ async function recordedLogLine(processInstance, line) {
1909
+ const retained = processInstance.status().logs.find((entry) => entry.line === line)
1910
+
1911
+ if (retained) return retained
1912
+ return await new Promise((resolve, reject) => {
1913
+ /** @param {import("../src/managed-process.js").ManagedProcessLog} entry - Newly retained output. */
1914
+ const onLog = (entry) => {
1915
+ if (entry.line !== line) return
1916
+ cleanup()
1917
+ resolve(entry)
1918
+ }
1919
+ /** @param {{code: number | null, signal: import("node:child_process").ChildProcess["signalCode"]}} exit - Exact process exit. */
1920
+ const onExit = (exit) => {
1921
+ cleanup()
1922
+ reject(new Error(`Process ${processInstance.id} exited before recording expected output ${JSON.stringify(line)}: ${JSON.stringify(exit)}`))
1923
+ }
1924
+ const cleanup = () => {
1925
+ processInstance.off("log", onLog)
1926
+ processInstance.off("exit", onExit)
1927
+ }
1928
+
1929
+ processInstance.on("log", onLog)
1930
+ processInstance.once("exit", onExit)
1931
+ })
1932
+ }
1933
+
1607
1934
  /**
1608
1935
  * @param {RollbridgeDaemon} daemon - Daemon.
1609
1936
  * @param {string} processId - Process id within the active release.