authorgravatar for luis.wacker@pagar.meLuis Gustavo Couto Wacker <luis.wacker@pagar.me> 2026-08-15 09:26:29-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:26:29+00:00
log160c76c6d1c5ab78441e21503cd10e7942b917f6
treec87f3e2a977d105d7143b2da058bd3c3484a84ab
parent86fb47afd724ea28e96c4657a0e99b66af926b87
signature Signed by PGP key B5690EEEBB952194

fix(desktop): keep probing backend readiness while the process is alive (#5526)


2 files changed, 103 insertions(+), 12 deletions(-)

apps/desktop/src/backend/DesktopBackendManager.test.ts+78
...@@ -701,6 +701,84 @@ describe("DesktopBackendManager", () => {...@@ -701,6 +701,84 @@ describe("DesktopBackendManager", () => {
701 ),701 ),
702 );702 );
703703
704 it.effect(
705 "re-probes readiness after the first budget expires while the backend is still alive",
706 () =>
707 Effect.scoped(
708 Effect.gen(function* () {
709 const requestUrls: Array<string> = [];
710 let requestCount = 0;
711 let readyCount = 0;
712 let readinessTimeoutCount = 0;
713 const firstProbe = yield* Deferred.make<void>();
714 const childExit = yield* Deferred.make<void>();
715
716 const spawnerLayer = Layer.succeed(
717 ChildProcessSpawner.ChildProcessSpawner,
718 ChildProcessSpawner.make(() =>
719 Effect.succeed(
720 makeProcess({
721 exitCode: Deferred.await(childExit).pipe(
722 Effect.as(ChildProcessSpawner.ExitCode(0)),
723 ),
724 }),
725 ),
726 ),
727 );
728
729 // The backend stays 503 through the first *two* readiness budgets
730 // and only becomes healthy (200) for the third round, i.e. it comes
731 // up well after the initial 50ms budget has expired.
732 const httpLayer = httpClientLayer((request) =>
733 Effect.gen(function* () {
734 requestCount += 1;
735 requestUrls.push(request.url);
736 yield* Deferred.succeed(firstProbe, void 0);
737 return responseForRequest(request, requestCount <= 2 ? 503 : 200);
738 }),
739 );
740
741 const runFiber = yield* DesktopBackendManager.runBackendProcess({
742 ...baseConfig,
743 desktopTelemetryStream: Stream.empty,
744 readinessTimeout: Duration.millis(50),
745 onReady: () =>
746 Effect.sync(() => {
747 readyCount += 1;
748 }),
749 onReadinessFailure: () =>
750 Effect.sync(() => {
751 readinessTimeoutCount += 1;
752 }),
753 }).pipe(Effect.provide(Layer.merge(spawnerLayer, httpLayer)), Effect.forkChild);
754
755 yield* Deferred.await(firstProbe);
756 assert.equal(readyCount, 0);
757 assert.equal(readinessTimeoutCount, 0);
758
759 // The first 50ms readiness budget expires while the backend still
760 // answers 503. The child is alive and may yet become healthy, so the
761 // probe must start a fresh round instead of stopping permanently —
762 // the pre-fix behavior left the app stuck on "Connecting to WSL…"
763 // forever even though the backend kept running.
764 yield* TestClock.adjust(Duration.millis(50));
765 assert.equal(readinessTimeoutCount, 1);
766 assert.equal(readyCount, 0);
767
768 // The second budget also expires (backend still 503), then the third
769 // round connects. The point is the probe persisted across budgets
770 // while the process was alive instead of giving up after the first.
771 yield* TestClock.adjust(Duration.millis(100));
772 assert.equal(readinessTimeoutCount, 2);
773 assert.equal(readyCount, 1);
774 assert.equal(requestUrls.length, 3);
775
776 yield* Deferred.succeed(childExit, void 0);
777 assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 0);
778 }).pipe(Effect.provide(TestClock.layer())),
779 ),
780 );
781
704 it.effect("starts the configured backend and closes the scoped process on stop", () =>782 it.effect("starts the configured backend and closes the scoped process on stop", () =>
705 Effect.scoped(783 Effect.scoped(
706 Effect.gen(function* () {784 Effect.gen(function* () {
apps/desktop/src/backend/DesktopBackendManager.ts+25-12
...@@ -563,20 +563,33 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (...@@ -563,20 +563,33 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
563 ).pipe(Effect.forkScoped),563 ).pipe(Effect.forkScoped),
564 );564 );
565 }565 }
566 yield* waitForHttpReady({566 // Probe readiness in a loop while the backend process is still alive
567 executablePath: options.executablePath,567 // instead of giving up after the first budget. A slow cold boot (the
568 entryPath: options.entryPath,568 // WSL bundle loading across /mnt/c, or a first launch right after an
569 cwd: options.cwd,569 // update) can exceed the initial readiness budget while the backend is
570 httpBaseUrl: options.httpBaseUrl,570 // about to come up moments later; a one-shot probe left the app stuck
571 timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,571 // on "Connecting to WSL…" forever even though the backend kept running
572 }).pipe(572 // and became healthy. Each round gets a fresh budget, and the forked
573 Effect.tap(() => options.onReady?.() ?? Effect.void),573 // loop is torn down with the run scope once the child exits.
574 Effect.catchTags({574 const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() =>
575 BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void,575 waitForHttpReady({
576 }),576 executablePath: options.executablePath,
577 Effect.forkScoped,577 entryPath: options.entryPath,
578 cwd: options.cwd,
579 httpBaseUrl: options.httpBaseUrl,
580 timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
581 }).pipe(
582 Effect.flatMap(() => options.onReady?.() ?? Effect.void),
583 Effect.as(true),
584 Effect.catchTags({
585 BackendReadinessTimeoutError: (error) =>
586 (options.onReadinessFailure?.(error) ?? Effect.void).pipe(Effect.as(false)),
587 }),
588 ),
578 );589 );
579590
591 yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped);
592
580 const exit = yield* handle.exitCode.pipe(593 const exit = yield* handle.exitCode.pipe(
581 Effect.mapError(594 Effect.mapError(
582 (cause) =>595 (cause) =>