From 72cb009890663e91443ce55ba43a6c097cf83be3 Mon Sep 17 00:00:00 2001 From: sheehanmunim Date: Thu, 8 Oct 2026 00:13:03 -0400 Subject: [PATCH] fix(client): a timed-out session check no longer locks the composer One slow /api/auth/session response left the environment session atom in Failure. The scope gate rejects any Failure and the atom never retried, so send stayed disabled ("This connection cannot change threads.") until the window reloaded. With a confirmed grant, transient failures now keep it and retry with capped backoff; rejected credentials and first loads still fail. Co-Authored-By: Claude Opus 5.5 --- .../client-runtime/src/state/session.test.ts | 104 ++++++++++++++++++ packages/client-runtime/src/state/session.ts | 38 ++++++- 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 packages/client-runtime/src/state/session.test.ts diff --git a/packages/client-runtime/src/state/session.test.ts b/packages/client-runtime/src/state/session.test.ts new file mode 100644 index 000000000000..915fbd79c46c --- /dev/null +++ b/packages/client-runtime/src/state/session.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, type AuthSessionState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; + +import { RelayConnectionTarget, type PreparedConnection } from "../connection/model.ts"; +import { EnvironmentRegistry } from "../connection/registry.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import * as RpcHttp from "../rpc/http.ts"; +import { createEnvironmentSessionAtoms } from "./session.ts"; + +const TARGET = new RelayConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Environment", +}); +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: "https://environment.example.test", + socketUrl: "wss://environment.example.test/ws", + httpAuthorization: { _tag: "Bearer", token: "bearer-token" }, + target: TARGET, +}; +const SESSION = { + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["bearer-access-token"], + sessionCookieName: "t3_session", + }, + scopes: ["orchestration:read", "orchestration:operate"], +} satisfies AuthSessionState; + +function makeSessionAtom(reply: (requestNumber: number) => Promise) { + let calls = 0; + const fetchFn: typeof fetch = () => reply(++calls); + const layer = Effect.gen(function* () { + const supervisor = { + target: TARGET, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + } as unknown as EnvironmentSupervisor["Service"]; + const registry = { + followStream: (_environmentId: EnvironmentId, stream: Stream.Stream) => + stream.pipe(Stream.provideService(EnvironmentSupervisor, supervisor)), + } as unknown as EnvironmentRegistry["Service"]; + return Layer.merge( + Layer.succeed(EnvironmentRegistry, registry), + RpcHttp.layerRemoteHttpClient(fetchFn), + ); + }).pipe(Layer.unwrap); + const atom = createEnvironmentSessionAtoms(Atom.runtime(layer)).sessionStateAtom( + TARGET.environmentId, + ); + return { atom, calls: () => calls }; +} + +const settle = ( + registry: AtomRegistry.AtomRegistry, + atom: ReturnType["atom"], +) => AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }).pipe(Effect.result); + +describe("environment session state", () => { + it.effect("keeps a confirmed grant through a failed refresh and retries it", () => + Effect.gen(function* () { + const session = makeSessionAtom(async (requestNumber) => { + if (requestNumber === 2) throw new TypeError("Failed to fetch"); + return Response.json(SESSION); + }); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const observed: Array = []; + registry.mount(session.atom); + registry.subscribe(session.atom, (result) => observed.push(result._tag)); + + expect(yield* settle(registry, session.atom)).toMatchObject({ success: SESSION }); + registry.refresh(session.atom); + expect(yield* settle(registry, session.atom)).toMatchObject({ success: SESSION }); + + expect(session.calls()).toBe(3); + expect(observed).not.toContain("Failure"); + }).pipe(Effect.scoped), + ); + + it.effect("reports a failed first load without retrying", () => + Effect.gen(function* () { + const session = makeSessionAtom(async () => { + throw new TypeError("Failed to fetch"); + }); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + registry.mount(session.atom); + + const result = yield* settle(registry, session.atom); + expect(result._tag).toBe("Failure"); + expect(AsyncResult.isFailure(registry.get(session.atom))).toBe(true); + expect(session.calls()).toBe(1); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 63c23d735fa9..516a1d1f6512 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,18 +1,22 @@ import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { HttpClient } from "effect/http"; import { AsyncResult, Atom } from "effect/reactivity"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; +import { mapRemoteEnvironmentError } from "../connection/errors.ts"; import * as EnvironmentRegistry from "../connection/registry.ts"; import type { PreparedConnection } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { environmentEndpointUrl } from "../environment/endpoint.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; +import type { RemoteEnvironmentRequestError } from "../rpc/http.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; import { followStreamInEnvironment } from "./environmentStreams.ts"; @@ -39,6 +43,20 @@ function initialConfigOption( // Bounded so a wedged environment cannot pin the permissions check (and with it // the settings UI) in a loading state for long. const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; +const SESSION_STATE_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), +); + +function isTransientSessionError( + error: SessionHttpClientUnavailable | RemoteEnvironmentRequestError, +): boolean { + return ( + error._tag !== "SessionHttpClientUnavailable" && + mapRemoteEnvironmentError(error)._tag === "ConnectionTransientError" + ); +} /** * Read the granted scopes of this client's session on one environment via its @@ -138,7 +156,17 @@ function makeEnvironmentSessionAtoms( if (prepared === null) { return Effect.never; } - return Effect.gen(function* () { + // Scope checks reject a Failure outright, so one slow response would + // revoke a grant this connection already confirmed until the client + // reloads. With a confirmed grant, keep it (the result stays waiting) + // and retry transient failures; rejected credentials still fail. + const hasConfirmedGrant = Option.isSome( + Option.flatMap( + get.self>(), + AsyncResult.value, + ), + ); + const fetchSession = Effect.gen(function* () { const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); const remoteAuthorization = yield* Effect.serviceOption( RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, @@ -151,6 +179,14 @@ function makeEnvironmentSessionAtoms( remoteAuthorization, }).pipe(Effect.provideService(HttpClient.HttpClient, client.value)); }); + return hasConfirmedGrant + ? fetchSession.pipe( + Effect.retry({ + while: isTransientSessionError, + schedule: SESSION_STATE_RETRY_SCHEDULE, + }), + ) + : fetchSession; }) .pipe( Atom.swr({ staleTime: 30_000, revalidateOnMount: true }),