Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
Expand Down
142 changes: 142 additions & 0 deletions apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -67,6 +69,7 @@ describe("DesktopLocalEnvironmentAuth", () => {
{
id: PRIMARY_LOCAL_ENVIRONMENT_ID,
label: Effect.succeed("Windows"),
waitForReady: () => Effect.succeed(true),
currentConfig: Effect.succeedSome(config),
},
]),
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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<boolean>();
const waiting = yield* Deferred.make<void>();
const configRef = yield* Ref.make(config);
const requests = yield* Ref.make<Array<{ url: string; credential: string | null }>>([]);
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<void>();
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);
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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();
}
Expand Down
Loading
Loading