Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b8b299b
fix(desktop): keep probing backend readiness while the process is alive
lgwacker Aug 6, 2026
bc0c114
fix(desktop): use Effect.catchTags for readiness re-probe per convention
lgwacker Aug 9, 2026
32c4c2f
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 9, 2026
cd53711
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 10, 2026
038a159
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 10, 2026
615e3fb
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 10, 2026
405a47b
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 10, 2026
ff8998a
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 10, 2026
b5111cb
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 11, 2026
0659aec
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 11, 2026
fa5367c
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 11, 2026
877bcd3
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 11, 2026
aa7fe1f
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 11, 2026
23094b8
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 11, 2026
0c6a92f
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 11, 2026
3331bbb
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 11, 2026
1f4be53
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 12, 2026
ec399ea
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 12, 2026
0cf3131
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 13, 2026
7297860
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 13, 2026
0c20d78
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 13, 2026
21e03cd
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 14, 2026
441ed06
Merge branch 'main' into fix/desktop-reprobe-readiness-while-alive
lgwacker Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions apps/desktop/src/backend/DesktopBackendManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = [];
let requestCount = 0;
let readyCount = 0;
let readinessTimeoutCount = 0;
const firstProbe = yield* Deferred.make<void>();
const childExit = yield* Deferred.make<void>();

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* () {
Expand Down
37 changes: 25 additions & 12 deletions apps/desktop/src/backend/DesktopBackendManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,20 +563,33 @@ 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")(() =>
waitForHttpReady({
executablePath: options.executablePath,
entryPath: options.entryPath,
cwd: options.cwd,
httpBaseUrl: options.httpBaseUrl,
timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
}).pipe(
Effect.flatMap(() => options.onReady?.() ?? Effect.void),
Effect.as(true),
Effect.catchTags({
BackendReadinessTimeoutError: (error) =>
(options.onReadinessFailure?.(error) ?? Effect.void).pipe(Effect.as(false)),
}),
),
);

yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped);

const exit = yield* handle.exitCode.pipe(
Effect.mapError(
(cause) =>
Expand Down
Loading