From ae651f52c0ab57a6b22ecd68871c0c53526ffd04 Mon Sep 17 00:00:00 2001 From: Luis Gustavo Wacker Date: Thu, 6 Aug 2026 11:12:34 -0300 Subject: [PATCH] fix(desktop): keep probing backend readiness while the process is alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop probed /.well-known/t3/environment exactly once with a fixed 60s budget. When the WSL backend cold-boots slower than that (server bundle loading across /mnt/c, or a first launch right after an update), the probe gave up permanently and the app stayed stuck on "Connecting to WSL…" forever even though the backend kept running and became healthy moments later. runBackendProcess now re-probes readiness in a loop while the child is still alive: each round gets a fresh budget, onReadinessFailure fires per round, and the loop is torn down with the run scope when the child exits. Model: DeepSeek (opencode-go/deepseek-v4-flash) via opencode. --- .../src/backend/DesktopBackendManager.test.ts | 78 +++++++++++++++++++ .../src/backend/DesktopBackendManager.ts | 40 ++++++---- 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index a32caa1fd37..3efc81ed5b6 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -701,6 +701,84 @@ describe("DesktopBackendManager", () => { ), ); + it.effect( + "re-probes readiness after the first budget expires while the backend is still alive", + () => + Effect.scoped( + Effect.gen(function* () { + const requestUrls: Array = []; + let requestCount = 0; + let readyCount = 0; + let readinessTimeoutCount = 0; + const firstProbe = yield* Deferred.make(); + const childExit = yield* Deferred.make(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(childExit).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }), + ), + ), + ); + + // The backend stays 503 through the first *two* readiness budgets + // and only becomes healthy (200) for the third round, i.e. it comes + // up well after the initial 50ms budget has expired. + const httpLayer = httpClientLayer((request) => + Effect.gen(function* () { + requestCount += 1; + requestUrls.push(request.url); + yield* Deferred.succeed(firstProbe, void 0); + return responseForRequest(request, requestCount <= 2 ? 503 : 200); + }), + ); + + const runFiber = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + readinessTimeout: Duration.millis(50), + onReady: () => + Effect.sync(() => { + readyCount += 1; + }), + onReadinessFailure: () => + Effect.sync(() => { + readinessTimeoutCount += 1; + }), + }).pipe(Effect.provide(Layer.merge(spawnerLayer, httpLayer)), Effect.forkChild); + + yield* Deferred.await(firstProbe); + assert.equal(readyCount, 0); + assert.equal(readinessTimeoutCount, 0); + + // The first 50ms readiness budget expires while the backend still + // answers 503. The child is alive and may yet become healthy, so the + // probe must start a fresh round instead of stopping permanently — + // the pre-fix behavior left the app stuck on "Connecting to WSL…" + // forever even though the backend kept running. + yield* TestClock.adjust(Duration.millis(50)); + assert.equal(readinessTimeoutCount, 1); + assert.equal(readyCount, 0); + + // The second budget also expires (backend still 503), then the third + // round connects. The point is the probe persisted across budgets + // while the process was alive instead of giving up after the first. + yield* TestClock.adjust(Duration.millis(100)); + assert.equal(readinessTimeoutCount, 2); + assert.equal(readyCount, 1); + assert.equal(requestUrls.length, 3); + + yield* Deferred.succeed(childExit, void 0); + assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 0); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + it.effect("starts the configured backend and closes the scoped process on stop", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index b50c7a55ed7..3a5e7fc7cb7 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -33,6 +33,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -563,19 +564,32 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ).pipe(Effect.forkScoped), ); } - yield* waitForHttpReady({ - executablePath: options.executablePath, - entryPath: options.entryPath, - cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - }).pipe( - Effect.tap(() => options.onReady?.() ?? Effect.void), - Effect.catchTags({ - BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void, - }), - Effect.forkScoped, - ); + // Probe readiness in a loop while the backend process is still alive + // instead of giving up after the first budget. A slow cold boot (the + // WSL bundle loading across /mnt/c, or a first launch right after an + // update) can exceed the initial readiness budget while the backend is + // about to come up moments later; a one-shot probe left the app stuck + // on "Connecting to WSL…" forever even though the backend kept running + // and became healthy. Each round gets a fresh budget, and the forked + // loop is torn down with the run scope once the child exits. + const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(function* () { + const outcome = yield* waitForHttpReady({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }).pipe(Effect.result); + + if (Result.isSuccess(outcome)) { + yield* options.onReady?.() ?? Effect.void; + return true; + } + yield* options.onReadinessFailure?.(outcome.failure) ?? Effect.void; + return false; + }); + + yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped); const exit = yield* handle.exitCode.pipe( Effect.mapError(