Skip to content
Merged
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
21 changes: 21 additions & 0 deletions apps/mobile/src/connection/app-state-wakeups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "@effect/vitest";

import {
MOBILE_BACKGROUND_RECONNECT_AFTER_MS,
mobileApplicationActiveWakeup,
} from "./app-state-wakeups";

describe("mobileApplicationActiveWakeup", () => {
it("uses a fast probe after a short interruption", () => {
expect(mobileApplicationActiveWakeup(null, 20_000)).toBe("application-active-probe");
expect(
mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS - 1),
).toBe("application-active-probe");
});

it("replaces the session after a meaningful background suspension", () => {
expect(
mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS),
).toBe("application-active-reconnect");
});
});
18 changes: 18 additions & 0 deletions apps/mobile/src/connection/app-state-wakeups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Wakeups } from "@t3tools/client-runtime/connection";

export const MOBILE_BACKGROUND_RECONNECT_AFTER_MS = 10_000;

export type MobileApplicationActiveWakeup = Extract<
Wakeups.ConnectionWakeup,
"application-active-probe" | "application-active-reconnect"
>;

export function mobileApplicationActiveWakeup(
backgroundedAtMs: number | null,
activeAtMs: number,
): MobileApplicationActiveWakeup {
return backgroundedAtMs !== null &&
activeAtMs - backgroundedAtMs >= MOBILE_BACKGROUND_RECONNECT_AFTER_MS
? "application-active-reconnect"
: "application-active-probe";
}
49 changes: 38 additions & 11 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import * as MobileStorage from "../persistence/mobile-storage";
import { appAtomRegistry } from "../state/atom-registry";
import { clearThreadOutboxEnvironment } from "../state/thread-outbox";
import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
import { mobileApplicationActiveWakeup } from "./app-state-wakeups";
import { connectionStorageLayer } from "./storage";

function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" {
Expand All @@ -54,27 +55,53 @@ const connectivityLayer = Connectivity.layer({
),
changes: Stream.callback((queue) =>
Effect.acquireRelease(
Effect.sync(() =>
Network.addNetworkStateListener((state) => {
Effect.sync(() => {
let active = true;
const networkSubscription = Network.addNetworkStateListener((state) => {
Queue.offerUnsafe(queue, networkStatus(state));
}),
),
(subscription) => Effect.sync(() => subscription.remove()),
});
const appStateSubscription = AppState.addEventListener("change", (state) => {
if (state !== "active") {
return;
}
void Network.getNetworkStateAsync()
.then((current) => {
if (active) {
Queue.offerUnsafe(queue, networkStatus(current));
}
})
.catch(() => undefined);
});
return {
close: () => {
active = false;
networkSubscription.remove();
appStateSubscription.remove();
},
};
}),
({ close }) => Effect.sync(close),
).pipe(Effect.asVoid),
),
});

const wakeupsLayer = Wakeups.layer({
changes: Stream.merge(
Stream.callback<"application-active">((queue) =>
Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) =>
Effect.acquireRelease(
Effect.sync(() =>
AppState.addEventListener("change", (state) => {
Effect.sync(() => {
let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null;
return AppState.addEventListener("change", (state) => {
if (state === "background") {
backgroundedAtMs = Date.now();
return;
}
if (state === "active") {
Queue.offerUnsafe(queue, "application-active");
Queue.offerUnsafe(queue, mobileApplicationActiveWakeup(backgroundedAtMs, Date.now()));
backgroundedAtMs = null;
}
}),
),
});
}),
(subscription) => Effect.sync(() => subscription.remove()),
).pipe(Effect.asVoid),
),
Expand Down
3 changes: 3 additions & 0 deletions docs/architecture/connection-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ The supervisor is the only retry owner.
seconds.
5. Connectivity changes, application activation, credential changes, and
explicit user retry interrupt the current wait and trigger a fresh attempt.
Application activation also resets the backoff ladder. Mobile briefly probes
a session after short interruptions and replaces it immediately after a
meaningful background suspension.
6. Authentication or configuration failures remain blocked until an external
wakeup changes the relevant input.
7. An involuntary session close keeps the registration and cache, then retries.
Expand Down
92 changes: 78 additions & 14 deletions packages/client-runtime/src/authorization/layer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as TestClock from "effect/testing/TestClock";

import * as ManagedRelay from "../relay/managedRelay.ts";
import { remoteHttpClientLayer } from "../rpc/http.ts";
Expand Down Expand Up @@ -155,6 +156,81 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
});

describe("RemoteEnvironmentAuthorization", () => {
it.effect("reuses a validated bearer descriptor while issuing fresh websocket tickets", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
responses: [
Response.json(DESCRIPTOR),
websocketTicket("first-ticket"),
websocketTicket("second-ticket"),
],
});

const [first, second] = yield* Effect.gen(function* () {
const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
const authorize = () =>
remote.authorizeBearer({
expectedEnvironmentId: ENVIRONMENT_ID,
httpBaseUrl: ENDPOINT.httpBaseUrl,
wsBaseUrl: ENDPOINT.wsBaseUrl,
bearerToken: "bearer-token",
});
return [yield* authorize(), yield* authorize()] as const;
}).pipe(Effect.provide(harness.layer));

expect(first.socketUrl).toContain("wsTicket=first-ticket");
expect(second.socketUrl).toContain("wsTicket=second-ticket");
expect(
harness.fetch.calls.filter(([url]) => String(url).endsWith("/.well-known/t3/environment")),
).toHaveLength(1);
expect(
harness.fetch.calls.filter(([url]) => String(url).endsWith("/api/auth/websocket-ticket")),
).toHaveLength(2);
}),
);

it.effect("revalidates a bearer descriptor after the cache expires", () =>
Effect.gen(function* () {
const reassignedEnvironmentId = EnvironmentId.make("environment-2");
const harness = yield* makeHarness({
responses: [
Response.json(DESCRIPTOR),
websocketTicket("first-ticket"),
Response.json({
...DESCRIPTOR,
environmentId: reassignedEnvironmentId,
}),
],
});

const failure = yield* Effect.gen(function* () {
const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
const authorize = () =>
remote.authorizeBearer({
expectedEnvironmentId: ENVIRONMENT_ID,
httpBaseUrl: ENDPOINT.httpBaseUrl,
wsBaseUrl: ENDPOINT.wsBaseUrl,
bearerToken: "bearer-token",
});

yield* authorize();
yield* TestClock.adjust("10 seconds");
return yield* authorize().pipe(Effect.flip);
}).pipe(Effect.provide(Layer.merge(harness.layer, TestClock.layer())));

expect(failure).toEqual(
expect.objectContaining({
_tag: "ConnectionBlockedError",
reason: "configuration",
detail: `Connected environment ${reassignedEnvironmentId} does not match ${ENVIRONMENT_ID}.`,
}),
);
expect(
harness.fetch.calls.filter(([url]) => String(url).endsWith("/.well-known/t3/environment")),
).toHaveLength(2);
}),
);

it.effect("reuses a valid persisted environment token without contacting the relay", () =>
Effect.gen(function* () {
const cached = new TokenStore.RemoteDpopAccessToken({
Expand Down Expand Up @@ -265,7 +341,7 @@ describe("RemoteEnvironmentAuthorization", () => {
}),
);

it.effect("refreshes a cached endpoint after consecutive transient failures", () =>
it.effect("refreshes a cached endpoint after its first transient failure", () =>
Effect.gen(function* () {
const cached = new TokenStore.RemoteDpopAccessToken({
environmentId: ENVIRONMENT_ID,
Expand All @@ -279,7 +355,6 @@ describe("RemoteEnvironmentAuthorization", () => {
initialToken: cached,
responses: [
new Response("endpoint unavailable", { status: 503 }),
new Response("endpoint still unavailable", { status: 503 }),
Response.json(DESCRIPTOR),
accessToken("replacement-access-token"),
websocketTicket("replacement-ticket"),
Expand All @@ -288,17 +363,6 @@ describe("RemoteEnvironmentAuthorization", () => {

const authorized = yield* Effect.gen(function* () {
const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
const firstFailure = yield* remote
.authorizeDpop({
expectedEnvironmentId: ENVIRONMENT_ID,
obtainBootstrap: harness.obtainBootstrap,
})
.pipe(Effect.flip);

expect(firstFailure._tag).toBe("ConnectionTransientError");
expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0);
expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toBe(cached);

return yield* remote.authorizeDpop({
expectedEnvironmentId: ENVIRONMENT_ID,
obtainBootstrap: harness.obtainBootstrap,
Expand All @@ -312,7 +376,7 @@ describe("RemoteEnvironmentAuthorization", () => {
accessToken: "replacement-access-token",
}),
);
expect(harness.fetch.calls).toHaveLength(5);
expect(harness.fetch.calls).toHaveLength(4);
}),
);

Expand Down
74 changes: 39 additions & 35 deletions packages/client-runtime/src/authorization/service.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EnvironmentId } from "@t3tools/contracts";
import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
import type { RelayManagedEndpoint } from "@t3tools/contracts/relay";
import {
exchangeRemoteDpopAccessToken,
Expand Down Expand Up @@ -58,7 +58,8 @@ export class RemoteEnvironmentAuthorization extends Context.Service<
>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {}

const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000;
const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2;
const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000;
const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;

function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {
return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"
Expand All @@ -79,25 +80,16 @@ export const make = Effect.gen(function* () {
const presentation = yield* ClientCapabilities.ClientPresentation;
const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore;
const httpClient = yield* HttpClient.HttpClient;
const cachedEndpointFailures = yield* Ref.make<ReadonlyMap<string, number>>(new Map());

const resetCachedEndpointFailures = (environmentId: string) =>
Ref.update(cachedEndpointFailures, (current) => {
if (!current.has(environmentId)) {
return current;
const bearerDescriptors = yield* Ref.make<
ReadonlyMap<
EnvironmentId,
{
readonly httpBaseUrl: string;
readonly descriptor: ExecutionEnvironmentDescriptor;
readonly validatedAtEpochMs: number;
}
const next = new Map(current);
next.delete(environmentId);
return next;
});

const recordCachedEndpointFailure = (environmentId: string) =>
Ref.modify(cachedEndpointFailures, (current) => {
const failureCount = (current.get(environmentId) ?? 0) + 1;
const next = new Map(current);
next.set(environmentId, failureCount);
return [failureCount, next] as const;
});
>
>(new Map());

const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")(
function* (input: {
Expand All @@ -108,15 +100,33 @@ export const make = Effect.gen(function* () {
readonly wsBaseUrl: string;
readonly bearerToken: string;
}) {
const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
);
const now = yield* Clock.currentTimeMillis;
const cachedDescriptor = (yield* Ref.get(bearerDescriptors)).get(input.expectedEnvironmentId);
const canReuseDescriptor =
cachedDescriptor?.httpBaseUrl === input.httpBaseUrl &&
cachedDescriptor.validatedAtEpochMs + BEARER_DESCRIPTOR_CACHE_TTL_MS > now;
const descriptor = canReuseDescriptor
? cachedDescriptor.descriptor
: yield* fetchDescriptor(input.httpBaseUrl).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
);
if (descriptor.environmentId !== input.expectedEnvironmentId) {
return yield* environmentMismatchError({
expected: input.expectedEnvironmentId,
actual: descriptor.environmentId,
});
}
if (!canReuseDescriptor) {
yield* Ref.update(bearerDescriptors, (current) => {
const next = new Map(current);
next.set(input.expectedEnvironmentId, {
httpBaseUrl: input.httpBaseUrl,
descriptor,
validatedAtEpochMs: now,
});
return next;
});
}
const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({
wsBaseUrl: input.wsBaseUrl,
httpBaseUrl: input.httpBaseUrl,
Expand All @@ -139,7 +149,7 @@ export const make = Effect.gen(function* () {
);

const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")(
function* (token: TokenStore.RemoteDpopAccessToken) {
function* (token: TokenStore.RemoteDpopAccessToken, timeoutMs?: number) {
const ticketProof = yield* signer
.createProof({
method: "POST",
Expand All @@ -160,6 +170,7 @@ export const make = Effect.gen(function* () {
httpBaseUrl: token.endpoint.httpBaseUrl,
accessToken: token.accessToken,
dpopProof: ticketProof,
...(timeoutMs === undefined ? {} : { timeoutMs }),
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
},
);
Expand Down Expand Up @@ -196,9 +207,11 @@ export const make = Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({
"connection.remote_token_cache": "hit",
});
const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result);
const cachedSocket = yield* createDpopSocketUrl(
cached.value,
CACHED_ENDPOINT_SOCKET_TIMEOUT_MS,
).pipe(Effect.result);
if (Result.isSuccess(cachedSocket)) {
yield* resetCachedEndpointFailures(input.expectedEnvironmentId);
return {
environmentId: cached.value.environmentId,
label: cached.value.label,
Expand All @@ -213,20 +226,11 @@ export const make = Effect.gen(function* () {
if (cachedSocket.failure._tag === "ConnectionBlockedError") {
return yield* mapDpopSocketError(cachedSocket.failure);
}
const mappedFailure = mapDpopSocketError(cachedSocket.failure);
if (mappedFailure._tag === "ConnectionTransientError") {
const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId);
if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) {
return yield* mappedFailure;
}
}
yield* tokenStore
.remove(input.expectedEnvironmentId)
.pipe(Effect.withSpan("environment.authorization.accessToken.remove"));
yield* resetCachedEndpointFailures(input.expectedEnvironmentId);
}

yield* resetCachedEndpointFailures(input.expectedEnvironmentId);
yield* Effect.annotateCurrentSpan({
"connection.remote_token_cache": "miss",
});
Expand Down
Loading
Loading