diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 68febbc6386ff..69f0418f83ddd 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -244,6 +244,21 @@ const bootstrap = Effect.gen(function* () { } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); + // The bundled renderer can paint while the configured backend boots. WSL-only + // keeps its splash because preflight can replace the primary endpoint. + const config = yield* primaryBackend.currentConfig; + if ( + !(settings.wslOnly === true && settings.wslBackendEnabled === true) && + Option.isSome(config) && + Option.isNone(config.value.preflightFailure) && + !(yield* Ref.get(state.quitting)) + ) { + yield* desktopWindow.ensureMain.pipe( + Effect.catch((error) => + logStartupError("failed to open main window during backend startup", { error }), + ), + ); + } yield* appActivation.start.pipe( Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 900afce9b860a..249c1ac32edb0 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,8 +1,10 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -67,6 +69,7 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), + waitForReady: () => Effect.succeed(true), currentConfig: Effect.succeedSome(config), }, ]), @@ -120,6 +123,7 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), + waitForReady: () => Effect.succeed(true), currentConfig: Effect.succeedSome({ ...config, bootstrap: { ...config.bootstrap, desktopBootstrapSecret: "desktop-secret" }, @@ -166,10 +170,148 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), + waitForReady: () => Effect.succeed(true), currentConfig: Effect.succeedSome(config), }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); + + const makeWaitingAuth = Effect.gen(function* () { + const readiness = yield* Deferred.make(); + const waiting = yield* Deferred.make(); + const configRef = yield* Ref.make(config); + const requests = yield* Ref.make>([]); + const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([ + { + id: PRIMARY_LOCAL_ENVIRONMENT_ID, + currentConfig: Ref.get(configRef).pipe(Effect.asSome), + waitForReady: (timeout: Duration.Duration) => + Deferred.succeed(waiting, undefined).pipe( + Effect.andThen(Deferred.await(readiness)), + Effect.timeoutOption(timeout), + Effect.map(Option.getOrElse(() => false)), + ), + }, + ]), + } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + return Ref.update(requests, (previous) => [ + ...previous, + { url: request.url, credential: new URLSearchParams(body).get("subject_token") }, + ]).pipe(Effect.as(tokenResponse(request))); + }), + ); + const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( + Effect.provide( + DesktopLocalEnvironmentAuth.layer.pipe(Layer.provide(Layer.mergeAll(poolLayer, httpLayer))), + ), + ); + return { auth, readiness, waiting, configRef, requests }; + }); + + it.effect("waits through a slow startup and exchanges once using the ready backend config", () => + Effect.gen(function* () { + const { auth, readiness, waiting, configRef, requests } = yield* makeWaitingAuth; + const first = yield* auth.getBearerToken.pipe(Effect.forkChild); + const second = yield* auth.getBearerToken.pipe(Effect.forkChild); + yield* Deferred.await(waiting); + yield* TestClock.adjust(Duration.seconds(30)); + assert.deepStrictEqual(yield* Ref.get(requests), []); + + // A restart can replace the endpoint and credential while startup is pending. + yield* Ref.set(configRef, { + ...config, + httpBaseUrl: new URL("http://127.0.0.1:4773"), + bootstrap: { ...config.bootstrap, desktopBootstrapToken: "replacement-token" }, + }); + yield* Deferred.succeed(readiness, true); + + assert.strictEqual(yield* Fiber.join(first), "desktop-bearer-token"); + assert.strictEqual(yield* Fiber.join(second), "desktop-bearer-token"); + assert.deepStrictEqual(yield* Ref.get(requests), [ + { url: "http://127.0.0.1:4773/oauth/token", credential: "replacement-token" }, + ]); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("stops waiting without an HTTP exchange when the backend stops", () => + Effect.gen(function* () { + const { auth, readiness, waiting, requests } = yield* makeWaitingAuth; + const pending = yield* auth.getBearerToken.pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(waiting); + yield* Deferred.succeed(readiness, false); + + const error = yield* Fiber.join(pending); + assert.strictEqual(error._tag, "DesktopLocalEnvironmentAuthBackendNotConfiguredError"); + assert.deepStrictEqual(yield* Ref.get(requests), []); + }), + ); + + it.effect("bounds readiness waiting and allows a new request after backend recovery", () => + Effect.gen(function* () { + const { auth, readiness, waiting, requests } = yield* makeWaitingAuth; + const settled = yield* Deferred.make(); + const pending = yield* auth.getBearerToken.pipe( + Effect.flip, + Effect.ensuring(Deferred.succeed(settled, undefined)), + Effect.forkChild, + ); + yield* Deferred.await(waiting); + yield* TestClock.adjust(Duration.minutes(2)); + + assert.isTrue(yield* Deferred.isDone(settled)); + const error = yield* Fiber.join(pending); + assert.strictEqual(error._tag, "DesktopLocalEnvironmentAuthBackendNotConfiguredError"); + assert.deepStrictEqual(yield* Ref.get(requests), []); + + yield* Deferred.succeed(readiness, true); + assert.strictEqual(yield* auth.getBearerToken, "desktop-bearer-token"); + assert.strictEqual((yield* Ref.get(requests)).length, 1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("releases the auth lock when a readiness wait is interrupted", () => + Effect.gen(function* () { + const { auth, readiness, waiting, requests } = yield* makeWaitingAuth; + const pending = yield* auth.getBearerToken.pipe(Effect.forkChild); + yield* Deferred.await(waiting); + yield* Fiber.interrupt(pending); + assert.deepStrictEqual(yield* Ref.get(requests), []); + + yield* Deferred.succeed(readiness, true); + assert.strictEqual(yield* auth.getBearerToken, "desktop-bearer-token"); + assert.strictEqual((yield* Ref.get(requests)).length, 1); + }), + ); + + it.effect("fails without an HTTP exchange when the primary backend is absent", () => + Effect.gen(function* () { + const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([]), + } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Unexpected HTTP exchange")), + ); + const error = yield* Effect.gen(function* () { + const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; + return yield* auth.getBearerToken.pipe(Effect.flip); + }).pipe( + Effect.provide( + DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provide(Layer.mergeAll(poolLayer, httpLayer)), + ), + ), + ); + assert.strictEqual(error._tag, "DesktopLocalEnvironmentAuthBackendNotConfiguredError"); + }), + ); + const makeExchange = (failures: number, failure: () => Response) => Effect.gen(function* () { const requestCount = yield* Ref.make(0); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 4d95dd075cf3e..4ace6dac06032 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -16,6 +16,8 @@ import * as HttpClient from "effect/http/HttpClient"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; +const BACKEND_READINESS_TIMEOUT = Duration.minutes(2); + const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(15); const BOOTSTRAP_TRANSIENT_RETRY_INTERVAL = Duration.millis(500); const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); @@ -80,7 +82,11 @@ export const make = Effect.gen(function* () { const instances = yield* pool.list; const primary = instances.find((instance) => instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID); - const configOption = primary === undefined ? Option.none() : yield* primary.currentConfig; + // The bundled renderer can request auth before the backend finishes starting. + if (primary === undefined || !(yield* primary.waitForReady(BACKEND_READINESS_TIMEOUT))) { + return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); + } + const configOption = yield* primary.currentConfig; if (Option.isNone(configOption)) { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index d17b530a86dd1..3fc84b8707dbb 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -220,6 +220,8 @@ function layerTest(input: { readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; readonly mainWindow: Ref.Ref>; + readonly beforeCreate?: Effect.Effect; + readonly onClearMain?: Effect.Effect; readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; readonly desktopSettings?: DesktopAppSettings.DesktopSettings; readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; @@ -275,6 +277,7 @@ function layerTest(input: { input.createdWindowOptions?.push(options); }).pipe( Effect.andThen(Ref.update(input.createCount, (count) => count + 1)), + Effect.andThen(input.beforeCreate ?? Effect.void), Effect.as(input.window), ), main: Ref.get(input.mainWindow), @@ -284,7 +287,10 @@ function layerTest(input: { ? Ref.get(input.mainWindow) : Effect.succeedSome(input.focusedWindow), setMain: (window) => Ref.set(input.mainWindow, Option.some(window)), - clearMain: () => Ref.set(input.mainWindow, Option.none()), + clearMain: () => + Ref.set(input.mainWindow, Option.none()).pipe( + Effect.andThen(input.onClearMain ?? Effect.void), + ), prepareReveal: () => Effect.succeed(false), reveal: (window) => Effect.sync(() => input.onReveal?.(window)), sendAll: () => Effect.void, @@ -664,6 +670,186 @@ describe("DesktopWindow", () => { }), ); + it.effect("opens the configured renderer before backend readiness without opening it twice", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + assert.strictEqual(yield* desktopWindow.ensureMain, fakeWindow.window); + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls, [["t3code-dev://app/"]]); + + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + assert.equal(fakeWindow.loadURL.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("shares an in-flight main window creation with activation and backend readiness", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createStarted = yield* Deferred.make(); + const finishCreate = yield* Deferred.make(); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + beforeCreate: Deferred.succeed(createStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishCreate)), + ), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const first = yield* desktopWindow.ensureMain.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(createStarted); + const ready = yield* desktopWindow + .handleBackendReady(new URL("http://127.0.0.1:3773")) + .pipe(Effect.forkChild({ startImmediately: true })); + const second = yield* desktopWindow.ensureMain.pipe( + Effect.forkChild({ startImmediately: true }), + ); + const activation = yield* desktopWindow.activate.pipe( + Effect.forkChild({ startImmediately: true }), + ); + assert.equal(yield* Ref.get(createCount), 1); + + yield* Deferred.succeed(finishCreate, undefined); + assert.strictEqual(yield* Fiber.join(first), fakeWindow.window); + assert.strictEqual(yield* Fiber.join(second), fakeWindow.window); + yield* Fiber.join(ready); + yield* Fiber.join(activation); + assert.equal(yield* Ref.get(createCount), 1); + assert.equal(fakeWindow.loadURL.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("keeps an early main window closed when the backend becomes ready", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const cleared = yield* Deferred.make(); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + onClearMain: Deferred.succeed(cleared, undefined).pipe(Effect.asVoid), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.ensureMain; + const closed = fakeWindow.windowListeners.get("closed"); + if (!closed) { + return yield* Effect.die("window closed listener was not registered"); + } + closed(); + yield* Deferred.await(cleared); + + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("reopens an explicitly closed early main window on activation before readiness", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const cleared = yield* Deferred.make(); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + onClearMain: Deferred.succeed(cleared, undefined).pipe(Effect.asVoid), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.ensureMain; + const closed = fakeWindow.windowListeners.get("closed"); + if (!closed) { + return yield* Effect.die("window closed listener was not registered"); + } + closed(); + yield* Deferred.await(cleared); + + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 2); + assert.strictEqual(Option.getOrThrow(yield* Ref.get(mainWindow)), fakeWindow.window); + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 2); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect.each(["before", "after"] as const)( + "waits for a previously ready backend on activation when closed %s it stops", + (closeOrder) => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const cleared = yield* Deferred.make(); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + onClearMain: Deferred.succeed(cleared, undefined).pipe(Effect.asVoid), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + if (closeOrder === "after") yield* desktopWindow.handleBackendNotReady; + const closed = fakeWindow.windowListeners.get("closed"); + if (!closed) return yield* Effect.die("window closed listener was not registered"); + closed(); + yield* Deferred.await(cleared); + if (closeOrder === "before") yield* desktopWindow.handleBackendNotReady; + + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 1); + assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); + + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 2); + assert.strictEqual(Option.getOrThrow(yield* Ref.get(mainWindow)), fakeWindow.window); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("retries a failed early main window creation when the backend becomes ready", () => + Effect.gen(function* () { + const main = makeFakeBrowserWindow(); + const scenario = yield* makeSplashScenario([null, main.window]); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const earlyExit = yield* Effect.exit(desktopWindow.ensureMain); + assert.equal(earlyExit._tag, "Failure"); + assert.isTrue(Option.isNone(yield* Ref.get(scenario.mainWindow))); + + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(scenario.createCalls), 2); + assert.strictEqual(Option.getOrThrow(yield* Ref.get(scenario.mainWindow)), main.window); + }).pipe(Effect.provide(scenario.layer)); + }), + ); + it.effect( "opens and reopens the window without backend readiness when local execution is disabled", () => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index d9f9805edab1a..609112b7886e0 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as Electron from "electron"; @@ -298,7 +299,12 @@ export const make = Effect.gen(function* () { const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; + // WSL-only startup and recovery still wait for readiness; native bootstrap + // can open the bundled renderer as soon as its endpoint is configured. const backendReadyRef = yield* Ref.make(false); + let backendHasBeenReady = false; + const mainWindowMutex = yield* Semaphore.make(1); + let mainWindowClosed = false; const splashWindowRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); @@ -806,6 +812,7 @@ export const make = Effect.gen(function* () { } window.on("closed", () => { + mainWindowClosed = true; clearDevelopmentLoadRetry(); clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); @@ -816,6 +823,7 @@ export const make = Effect.gen(function* () { const createMain = Effect.gen(function* () { const window = yield* createWindow(); + mainWindowClosed = false; yield* electronWindow.setMain(window); yield* logWindowInfo("main window created"); return window; @@ -827,7 +835,7 @@ export const make = Effect.gen(function* () { return existingWindow.value; } return yield* createMain; - }).pipe(Effect.withSpan("desktop.window.ensureMain")); + }).pipe(mainWindowMutex.withPermits(1), Effect.withSpan("desktop.window.ensureMain")); const revealOrCreateMain = Effect.gen(function* () { const window = yield* ensureMain; @@ -842,9 +850,7 @@ export const make = Effect.gen(function* () { const createMainIfBackendReady = Effect.gen(function* () { if (yield* waitingForBackend) return; - const existingWindow = yield* currentMainWindow; - if (Option.isSome(existingWindow)) return; - yield* createMain; + yield* ensureMain; }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); const showConnectingSplash = Effect.gen(function* () { @@ -932,6 +938,8 @@ export const make = Effect.gen(function* () { yield* electronWindow.reveal(existingWindow.value); return; } + const reopenDuringStartup = mainWindowClosed && !backendHasBeenReady; + mainWindowClosed = false; const backendReady = yield* Ref.get(backendReadyRef); if (!backendReady) { const splash = yield* Ref.get(splashWindowRef); @@ -940,14 +948,20 @@ export const make = Effect.gen(function* () { return; } } + if (reopenDuringStartup) { + yield* ensureMain; + return; + } yield* createMainIfBackendReady; }).pipe(Effect.withSpan("desktop.window.activate")), createMainIfBackendReady, showConnectingSplash, handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { yield* Ref.set(backendReadyRef, true); + backendHasBeenReady = true; yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); - yield* createMainIfBackendReady; + // A cold start may finish after the user has already closed its window. + if (!mainWindowClosed) yield* createMainIfBackendReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"),