diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx
index 012d00737d1df..842965dbf1575 100644
--- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx
+++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx
@@ -12,7 +12,10 @@ import {
isLearned,
} from "@t3tools/client-runtime/connection";
import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts";
-import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";
+import {
+ AUTH_SCOPE_OPTIONS,
+ sessionPermissionsUnavailableMessage,
+} from "@t3tools/shared/authScopeOptions";
import { AsyncResult } from "effect/reactivity";
import * as Option from "effect/Option";
import { useState } from "react";
@@ -163,8 +166,14 @@ export function EnvironmentRoutesSection({
>
) : (
- Permissions not checked. Connect to this environment to view this session’s
- permissions.
+ {sessionPermissionsUnavailableMessage({
+ connected: activeRouteId !== null,
+ failed: sessionResult._tag === "Failure",
+ rejected:
+ sessionResult._tag !== "Failure" &&
+ !sessionResult.waiting &&
+ session?.authenticated === false,
+ })}
)}
diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx
index cd9d8e03c9c1f..c581cbc688142 100644
--- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx
@@ -8,6 +8,7 @@ import {
type ServerProvider,
} from "@t3tools/contracts";
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
+import { sessionPermissionsUnavailableMessage } from "@t3tools/shared/authScopeOptions";
import * as Cause from "effect/Cause";
import * as Exit from "effect/Exit";
import { AsyncResult } from "effect/reactivity";
@@ -208,11 +209,14 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
) : !allowed ? (
- {AsyncResult.isFailure(sessionResult)
- ? "Could not verify your permissions. Reconnect to try again."
- : session === null
- ? "Checking permissions…"
- : "This connection does not have permission to manage the environment."}
+ {AsyncResult.isFailure(sessionResult) || session === null || !session.authenticated
+ ? sessionPermissionsUnavailableMessage({
+ connected,
+ failed: AsyncResult.isFailure(sessionResult),
+ rejected:
+ !AsyncResult.isFailure(sessionResult) && session?.authenticated === false,
+ })
+ : "This connection does not have permission to manage the environment."}
) : null}
{error ? (
diff --git a/apps/web/src/components/settings/SessionPermissions.tsx b/apps/web/src/components/settings/SessionPermissions.tsx
index 1cbff3d750cf3..30f61ddd80371 100644
--- a/apps/web/src/components/settings/SessionPermissions.tsx
+++ b/apps/web/src/components/settings/SessionPermissions.tsx
@@ -1,5 +1,8 @@
import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts";
-import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";
+import {
+ AUTH_SCOPE_OPTIONS,
+ sessionPermissionsUnavailableMessage,
+} from "@t3tools/shared/authScopeOptions";
import { useEnvironmentSessionState } from "~/state/session";
export function SessionPermissions({
@@ -32,7 +35,11 @@ export function SessionPermissions({
) : (
- Permissions not checked. Connect to this environment to view this session’s permissions.
+ {sessionPermissionsUnavailableMessage({
+ connected,
+ failed: session.hasError,
+ rejected: !session.hasError && !session.isPending && session.data?.authenticated === false,
+ })}
);
}
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 0000000000000..740ab2229bdb4
--- /dev/null
+++ b/packages/client-runtime/src/state/session.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from "@effect/vitest";
+import { EnvironmentId, type AuthSessionState } from "@t3tools/contracts";
+import * as Clock from "effect/Clock";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Queue from "effect/Queue";
+import * as Stream from "effect/Stream";
+import * as SubscriptionRef from "effect/SubscriptionRef";
+import * as TestClock from "effect/testing/TestClock";
+import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
+
+import {
+ AVAILABLE_CONNECTION_STATE,
+ PrimaryConnectionTarget,
+ type PreparedConnection,
+} from "../connection/model.ts";
+import * as EnvironmentRegistry from "../connection/registry.ts";
+import * as EnvironmentSupervisor from "../connection/supervisor.ts";
+import * as RpcHttp from "../rpc/http.ts";
+import type { RpcSession } from "../rpc/session.ts";
+import { createEnvironmentSessionAtoms } from "./session.ts";
+
+const TARGET = new PrimaryConnectionTarget({
+ environmentId: EnvironmentId.make("environment-1"),
+ label: "Test environment",
+ httpBaseUrl: "https://environment.example.test",
+ wsBaseUrl: "wss://environment.example.test",
+});
+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: [],
+ sessionMethods: [],
+ sessionCookieName: "t3_session",
+ },
+ scopes: ["orchestration:read"],
+} satisfies AuthSessionState;
+
+const rpcSession = (): RpcSession => ({ closed: Effect.never }) as unknown as RpcSession;
+
+const makeHarness = Effect.fn("TestSessionState.makeHarness")(function* (
+ reply: (requestNumber: number) => Promise,
+) {
+ let requestCount = 0;
+ const transport = yield* SubscriptionRef.make(Option.some(rpcSession()));
+ const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
+ target: TARGET,
+ state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
+ session: transport,
+ prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
+ connect: Effect.void,
+ disconnect: Effect.void,
+ retryNow: Effect.void,
+ } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
+ const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = (
+ _environmentId,
+ stream,
+ ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor);
+ const runtime = Atom.runtime(
+ Layer.mergeAll(
+ Layer.succeed(
+ EnvironmentRegistry.EnvironmentRegistry,
+ EnvironmentRegistry.EnvironmentRegistry.of({
+ followStream,
+ } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]),
+ ),
+ RpcHttp.layerRemoteHttpClient(async () => reply(++requestCount)),
+ // The test's clock, so the retry wait moves with `TestClock.adjust`.
+ Layer.succeed(Clock.Clock, yield* Clock.Clock),
+ ),
+ );
+ const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
+ Effect.sync(() => registry.dispose()),
+ );
+ const results = yield* Queue.unbounded>();
+ const atom = createEnvironmentSessionAtoms(runtime).sessionStateAtom(TARGET.environmentId);
+ const unsubscribe = registry.subscribe(atom, (result) => Queue.offerUnsafe(results, result), {
+ immediate: true,
+ });
+ yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
+ const nextSettled = Effect.fn("TestSessionState.nextSettled")(function* () {
+ for (;;) {
+ const result = yield* Queue.take(results);
+ if (!result.waiting && result._tag !== "Initial") return result;
+ }
+ });
+ return { transport, nextSettled };
+});
+
+const unreachable = () => Promise.reject(new TypeError("Network request failed"));
+
+describe("environment session state", () => {
+ it.effect("fetches permissions again when the connection is re-established", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness((requestNumber) =>
+ requestNumber === 1 ? unreachable() : Promise.resolve(Response.json(SESSION)),
+ );
+ expect((yield* harness.nextSettled())._tag).toBe("Failure");
+
+ yield* SubscriptionRef.set(harness.transport, Option.some(rpcSession()));
+
+ expect(yield* harness.nextSettled()).toMatchObject({ _tag: "Success", value: SESSION });
+ }),
+ );
+
+ it.effect("retries a failed permission check with backoff that restarts per connection", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness((requestNumber) =>
+ requestNumber <= 3 ? unreachable() : Promise.resolve(Response.json(SESSION)),
+ );
+ expect((yield* harness.nextSettled())._tag).toBe("Failure");
+
+ yield* TestClock.adjust("2 seconds");
+ expect((yield* harness.nextSettled())._tag).toBe("Failure");
+
+ yield* SubscriptionRef.set(harness.transport, Option.some(rpcSession()));
+ expect((yield* harness.nextSettled())._tag).toBe("Failure");
+
+ yield* TestClock.adjust("2 seconds");
+ expect(yield* harness.nextSettled()).toMatchObject({ _tag: "Success", value: SESSION });
+ }),
+ );
+});
diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts
index efdf0b37c0a09..90afa29f9d05d 100644
--- a/packages/client-runtime/src/state/session.ts
+++ b/packages/client-runtime/src/state/session.ts
@@ -14,6 +14,7 @@ import type { PreparedConnection } from "../connection/model.ts";
import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import * as ManagedRelay from "../relay/managedRelay.ts";
import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";
+import type { RpcSession } from "../rpc/session.ts";
import { safeErrorLogAttributes } from "../errors/safeLog.ts";
import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
import { followStreamInEnvironment } from "./environmentStreams.ts";
@@ -39,6 +40,8 @@ function initialConfigOption(
const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000;
const MAX_SESSION_STATE_TIMEOUT_MS = 60_000;
+const SESSION_STATE_RETRY_BASE_MS = 2_000;
+const SESSION_STATE_RETRY_MAX_MS = 60_000;
export const fetchEnvironmentSessionState = Effect.fn(
"clientRuntime.state.fetchEnvironmentSessionState",
@@ -136,13 +139,38 @@ function makeEnvironmentSessionAtoms(
).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)),
);
- const sessionStateAtom = Atom.family((environmentId: EnvironmentId) =>
- runtime
+ const transportSessionAtom = Atom.family((environmentId: EnvironmentId) =>
+ runtime.atom(
+ followStreamInEnvironment(
+ environmentId,
+ Stream.unwrap(
+ EnvironmentSupervisor.EnvironmentSupervisor.pipe(
+ Effect.map((supervisor) => SubscriptionRef.changes(supervisor.session)),
+ ),
+ ),
+ ),
+ { initialValue: Option.none() },
+ ),
+ );
+
+ const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => {
+ let consecutiveFailures = 0;
+ let backoffConnection: RpcSession | null = null;
+ return runtime
.atom((get) => {
const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId)));
- if (prepared === null) {
+ // Each established connection fetches anew, and a failure retries while it stays up,
+ // so a fetch that failed mid-reconnect cannot outlive the socket that recovered.
+ const connection = AsyncResult.getOrElse(get(transportSessionAtom(environmentId)), () =>
+ Option.none(),
+ );
+ if (prepared === null || Option.isNone(connection)) {
return Effect.never;
}
+ if (connection.value !== backoffConnection) {
+ backoffConnection = connection.value;
+ consecutiveFailures = 0;
+ }
return Effect.gen(function* () {
const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner);
const remoteAuthorization = yield* Effect.serviceOption(
@@ -155,14 +183,31 @@ function makeEnvironmentSessionAtoms(
signer,
remoteAuthorization,
}).pipe(Effect.provideService(HttpClient.HttpClient, client.value));
- });
+ }).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ consecutiveFailures = 0;
+ }),
+ ),
+ Effect.tapError(() => {
+ const delayMs = Math.min(
+ SESSION_STATE_RETRY_BASE_MS * 2 ** consecutiveFailures,
+ SESSION_STATE_RETRY_MAX_MS,
+ );
+ consecutiveFailures += 1;
+ return Effect.sleep(delayMs).pipe(
+ Effect.andThen(Effect.sync(() => get.refreshSelf())),
+ Effect.forkScoped,
+ );
+ }),
+ );
})
.pipe(
Atom.swr({ staleTime: 30_000, revalidateOnMount: true }),
Atom.setIdleTTL(5 * 60_000),
Atom.withLabel(`environment-session-state:${environmentId}`),
- ),
- );
+ );
+ });
const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make(
diff --git a/packages/shared/src/authScopeOptions.ts b/packages/shared/src/authScopeOptions.ts
index d443e50326d28..9a2261a09f3e9 100644
--- a/packages/shared/src/authScopeOptions.ts
+++ b/packages/shared/src/authScopeOptions.ts
@@ -104,3 +104,19 @@ export const AUTH_SCOPE_OPTIONS: ReadonlyArray<{
description: "Change managed tunnel connectivity.",
},
];
+
+/** Shown instead of the granted scopes while they are unknown, so every surface says the same thing. */
+export function sessionPermissionsUnavailableMessage(input: {
+ readonly connected: boolean;
+ /** The check errored; the client retries it while connected. */
+ readonly failed: boolean;
+ /** The environment answered that this session is not signed in; retrying will not change that. */
+ readonly rejected: boolean;
+}): string {
+ if (!input.connected) return "Connect to this environment to view this session’s permissions.";
+ if (input.rejected)
+ return "This environment did not accept your session. Reconnect to try again.";
+ return input.failed
+ ? "Could not verify your permissions. Retrying automatically."
+ : "Checking permissions…";
+}