From f255391d4dd1c781fda8ac7acd796a918c99a6ad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 02:57:19 -0700 Subject: [PATCH 1/6] structure client RPC unavailable errors Co-authored-by: codex --- .../client-runtime/src/rpc/client.test.ts | 29 ++++++++++++++++++- packages/client-runtime/src/rpc/client.ts | 23 ++++++++++----- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 507d137cacc..64d06184dc3 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -25,7 +25,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; +import { + EnvironmentRpcRequestObserver, + EnvironmentRpcUnavailableError, + request, + runStream, + subscribe, +} from "./client.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -77,6 +83,27 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { }); describe("environment RPC", () => { + it.effect("reports the disconnected environment and requested method", () => + Effect.gen(function* () { + const { supervisor } = yield* makeHarness(); + + const error = yield* request(WS_METHODS.cloudGetRelayClientStatus, {}).pipe( + Effect.flip, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(error).toBeInstanceOf(EnvironmentRpcUnavailableError); + expect(error).toMatchObject({ + environmentId: TARGET.environmentId, + environmentLabel: TARGET.label, + method: WS_METHODS.cloudGetRelayClientStatus, + }); + expect(error.message).toBe( + `Test environment is not connected for RPC method ${WS_METHODS.cloudGetRelayClientStatus}.`, + ); + }), + ); + it.effect("observes unary requests until they complete", () => Effect.gen(function* () { const observations: string[] = []; diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 92892431e45..4295dd2772e 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -1,4 +1,4 @@ -import { ORCHESTRATION_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; +import { EnvironmentId, ORCHESTRATION_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import type * as Duration from "effect/Duration"; @@ -15,10 +15,16 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; export class EnvironmentRpcUnavailableError extends Schema.TaggedErrorClass()( "EnvironmentRpcUnavailableError", { - environmentId: Schema.String, - message: Schema.String, + environmentId: EnvironmentId, + environmentLabel: Schema.String, + method: Schema.optionalKey(Schema.String), }, -) {} +) { + override get message(): string { + const method = this.method === undefined ? "" : ` for RPC method ${this.method}`; + return `${this.environmentLabel} is not connected${method}.`; + } +} export interface EnvironmentRpcRequestObservation { readonly environmentId: string; @@ -85,7 +91,7 @@ export type EnvironmentRpcStreamFailure = ? E : never; -const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { +const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* (method?: string) { const supervisor = yield* EnvironmentSupervisor; return yield* SubscriptionRef.get(supervisor.session).pipe( Effect.flatMap( @@ -94,7 +100,8 @@ const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { Effect.fail( new EnvironmentRpcUnavailableError({ environmentId: supervisor.target.environmentId, - message: `${supervisor.target.label} is not connected.`, + environmentLabel: supervisor.target.label, + ...(method === undefined ? {} : { method }), }), ), onSome: Effect.succeed, @@ -111,7 +118,7 @@ export const request = Effect.fn("EnvironmentRpc.request")(function* < "environment.id": supervisor.target.environmentId, "rpc.method": tag, }); - const session = yield* currentSession(); + const session = yield* currentSession(tag); const observer = yield* EnvironmentRpcRequestObserver; const method = session.client[tag] as ( input: EnvironmentRpcInput, @@ -132,7 +139,7 @@ export function runStream( EnvironmentSupervisor > { return Stream.unwrap( - currentSession().pipe( + currentSession(tag).pipe( Effect.map((session) => { const method = session.client[tag] as ( input: EnvironmentRpcInput, From ee6201fbb80c629db45fc76a3bf6a6124ffb54be Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:37:15 -0700 Subject: [PATCH 2/6] Preserve typed RPC retry classification Co-authored-by: codex --- apps/mobile/src/state/thread-outbox-model.ts | 4 ++++ apps/mobile/src/state/thread-outbox.test.ts | 10 ++++++++++ packages/client-runtime/src/rpc/client.ts | 2 ++ 3 files changed, 16 insertions(+) diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index aa7a1055136..a35f29a2994 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -1,4 +1,5 @@ import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import { isEnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc"; import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -109,6 +110,9 @@ function errorMessage(error: unknown): string | null { } export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { + if (isEnvironmentRpcUnavailableError(error)) { + return true; + } if ( typeof error === "object" && error !== null && diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index d2634fb966f..9a1ea4dca5f 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc"; import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { AtomRegistry } from "effect/unstable/reactivity"; @@ -215,6 +216,15 @@ describe("thread outbox", () => { }); it("retries transport failures but drops deterministic command failures", () => { + expect( + shouldRetryThreadOutboxDelivery( + new EnvironmentRpcUnavailableError({ + environmentId: EnvironmentId.make("environment-1"), + environmentLabel: "Test environment", + method: "thread.turn.start", + }), + ), + ).toBe(true); expect(shouldRetryThreadOutboxDelivery(new Error("Socket is not connected"))).toBe(true); expect( shouldRetryThreadOutboxDelivery({ diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 4295dd2772e..c2ab4b5b91c 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -26,6 +26,8 @@ export class EnvironmentRpcUnavailableError extends Schema.TaggedErrorClass Date: Sat, 20 Jun 2026 10:58:40 -0700 Subject: [PATCH 3/6] fix(client-runtime): bound RPC transport diagnostics Co-authored-by: codex --- packages/client-runtime/src/rpc/client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c2ab4b5b91c..1e136768c19 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -204,7 +204,8 @@ export function subscribe( Effect.logWarning( "Durable RPC subscription lost its transport; waiting for the next session.", { - cause: Cause.pretty(cause), + errorTag: "RpcClientError", + reasonCount: cause.reasons.length, method: tag, environmentId: supervisor.target.environmentId, }, From 0c5fe3cad7db3e7936570f45c947386f970ba251 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:17:10 -0700 Subject: [PATCH 4/6] test(client-runtime): focus error mapping coverage Co-authored-by: codex --- .../src/connection/errors.test.ts | 68 +------------------ 1 file changed, 3 insertions(+), 65 deletions(-) diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts index c078408c48a..55b31edbc19 100644 --- a/packages/client-runtime/src/connection/errors.test.ts +++ b/packages/client-runtime/src/connection/errors.test.ts @@ -1,72 +1,13 @@ -import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; -import { RelayAuthInvalidError } from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; -import { mapManagedRelayError, mapRemoteEnvironmentError } from "./errors.ts"; -import * as ManagedRelay from "../relay/managedRelay.ts"; +import { mapRemoteEnvironmentError } from "./errors.ts"; import { RemoteEnvironmentAuthFetchError, RemoteEnvironmentAuthUndeclaredStatusError, } from "../rpc/http.ts"; describe("connection error mapping", () => { - it("retains the managed relay request as the cause when classifying a protected error", () => { - const relayError = new RelayAuthInvalidError({ - code: "auth_invalid", - reason: "invalid_bearer", - traceId: "relay-trace-id", - }); - const source = new ManagedRelay.ManagedRelayRequestFailedError({ - action: "connect relay environment", - cause: relayError, - relayError, - traceId: relayError.traceId, - }); - - const error = mapManagedRelayError(source); - - expect(error).toMatchObject({ - _tag: "ConnectionBlockedError", - reason: "authentication", - traceId: "relay-trace-id", - }); - expect(error.cause).toBe(source); - }); - - it("retains a managed relay timeout and its structured activity", () => { - const source = new ManagedRelay.ManagedRelayRequestTimeoutError({ - activity: "Relay environment connection", - timeoutMs: 10_000, - }); - - const error = mapManagedRelayError(source); - - expect(error).toMatchObject({ - _tag: "ConnectionTransientError", - reason: "timeout", - }); - expect(error.cause).toBe(source); - expect(source.activity).toBe("Relay environment connection"); - }); - - it("retains structured remote authorization failures", () => { - const source = new EnvironmentAuthInvalidError({ - code: "auth_invalid", - reason: "invalid_credential", - traceId: "environment-trace-id", - }); - - const error = mapRemoteEnvironmentError(source); - - expect(error).toMatchObject({ - _tag: "ConnectionBlockedError", - reason: "authentication", - traceId: "environment-trace-id", - }); - expect(error.cause).toBe(source); - }); - - it("retains local transport failures without deriving their message from the cause", () => { + it("keeps transport diagnostics structured and redacted", () => { const transportCause = new Error("sensitive transport implementation detail"); const requestUrl = "https://environment-user:environment-password@environment.example.test/private/session?access_token=environment-secret#environment-fragment"; @@ -82,8 +23,6 @@ describe("connection error mapping", () => { _tag: "ConnectionTransientError", reason: "network", }); - expect(error.cause).toBe(source); - expect(source.cause).toBe(transportCause); expect(source).toMatchObject({ requestUrlInputLength: requestUrl.length, requestUrlProtocol: "https:", @@ -102,7 +41,7 @@ describe("connection error mapping", () => { } }); - it("retains the HTTP client cause for undeclared statuses", () => { + it("keeps undeclared status diagnostics structured and redacted", () => { const cause = new Error("upstream response metadata"); const requestUrl = "https://environment-user:environment-password@environment.example.test/private/session?access_token=environment-secret#environment-fragment"; @@ -114,7 +53,6 @@ describe("connection error mapping", () => { requestUrlProtocol: "https:", requestUrlHostname: "environment.example.test", }); - expect(error.cause).toBe(cause); expect(error).not.toHaveProperty("requestUrl"); }); }); From c8fc2ce65b72192f7286609dcce70de17da46b44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 03:13:47 -0700 Subject: [PATCH 5/6] refactor(client): preserve connection preparation causes Co-authored-by: codex --- apps/mobile/src/connection/platform.ts | 6 ++++-- apps/web/src/connection/platform.ts | 6 ++++-- packages/client-runtime/src/connection/onboarding.test.ts | 5 ++++- packages/client-runtime/src/connection/onboarding.ts | 6 ++++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 769632a8fcb..0e33f2f0ec7 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -100,7 +100,8 @@ const capabilitiesLayer = Layer.succeedContext( (error) => new ConnectionTransientError({ reason: "network", - detail: error.message, + detail: "Could not read the T3 Cloud session token.", + cause: error, }), ), ); @@ -126,7 +127,8 @@ const capabilitiesLayer = Layer.succeedContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - detail: `Could not load the mobile device identity: ${String(cause)}`, + detail: "Could not load the mobile device identity.", + cause, }), }).pipe(Effect.map(Option.some)), }), diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 1b7cba5cbfd..bd5624bba07 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -172,7 +172,8 @@ const capabilitiesLayer = Layer.effectContext( (error) => new ConnectionTransientError({ reason: "network", - detail: error.message, + detail: "Could not read the T3 Cloud session token.", + cause: error, }), ), ); @@ -194,7 +195,8 @@ const capabilitiesLayer = Layer.effectContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - detail: `Could not load the desktop primary credential: ${String(cause)}`, + detail: "Could not load the desktop primary credential.", + cause, }), }).pipe(Effect.map(Option.fromNullishOr)), }); diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 9bee0dad6fb..2bdd57dbea0 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -155,7 +155,10 @@ describe("connection onboarding", () => { expect(error).toMatchObject({ _tag: "ConnectionBlockedError", reason: "configuration", - message: "Enter a backend URL.", + message: "The pairing details are invalid.", + cause: expect.objectContaining({ + message: "Enter a backend URL.", + }), }); expect(calls).toEqual([]); }), diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index e76bcd50a2c..39db24a8a1b 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -77,7 +77,8 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso catch: (cause) => new ConnectionBlockedError({ reason: "configuration", - detail: cause instanceof Error ? cause.message : "The pairing details are invalid.", + detail: "The pairing details are invalid.", + cause, }), }); }, @@ -189,7 +190,8 @@ export const prepareBearerConnectionUpdate = Effect.fn( catch: (cause) => new ConnectionBlockedError({ reason: "configuration", - detail: cause instanceof Error ? cause.message : "The environment URL is invalid.", + detail: "The environment URL is invalid.", + cause, }), }); const connectionId = entry.target.connectionId; From 8a2da4c6efde8c3e82304e67b440046a760162cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:26:34 -0700 Subject: [PATCH 6/6] Preserve desktop SSH connection causes Co-authored-by: codex --- apps/web/src/connection/platform.test.ts | 25 ++++++++++++++++++++---- apps/web/src/connection/platform.ts | 7 +++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/web/src/connection/platform.test.ts b/apps/web/src/connection/platform.test.ts index 2b428e26698..3027105b31c 100644 --- a/apps/web/src/connection/platform.test.ts +++ b/apps/web/src/connection/platform.test.ts @@ -1,3 +1,4 @@ +import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; import { AuthStandardClientScopes, EnvironmentId, @@ -18,7 +19,7 @@ const TARGET: DesktopSshEnvironmentTarget = { function makeBridge( calls: string[], - options?: { readonly failDescriptor?: boolean }, + options?: { readonly descriptorError?: Error }, ): DesktopBridge { return { ensureSshEnvironment: async (target: DesktopSshEnvironmentTarget) => { @@ -32,8 +33,8 @@ function makeBridge( }, fetchSshEnvironmentDescriptor: async () => { calls.push("descriptor"); - if (options?.failDescriptor === true) { - throw new Error("descriptor unavailable"); + if (options?.descriptorError !== undefined) { + throw options.descriptorError; } return { environmentId: EnvironmentId.make("environment-ssh"), @@ -78,11 +79,27 @@ describe("desktop SSH pairing", () => { const calls: string[] = []; yield* provisionDesktopSshEnvironment( - makeBridge(calls, { failDescriptor: true }), + makeBridge(calls, { descriptorError: new Error("descriptor unavailable") }), TARGET, ).pipe(Effect.flip); expect(calls).toEqual(["ensure", "descriptor"]); }), ); + + it.effect("preserves SSH preparation causes without exposing their message", () => + Effect.gen(function* () { + const cause = new Error("descriptor response contained a private endpoint"); + + const error = yield* provisionDesktopSshEnvironment( + makeBridge([], { descriptorError: cause }), + TARGET, + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error.message).toBe("Could not prepare the SSH environment."); + expect(error.message).not.toContain(cause.message); + expect(error.cause).toBe(cause); + }), + ); }); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index bd5624bba07..45346f07e72 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -111,11 +111,13 @@ function sshPreparationError(cause: unknown) { return new ConnectionBlockedError({ reason: "authentication", detail: message, + cause, }); } return new ConnectionTransientError({ reason: "remote-unavailable", - detail: `Could not prepare the SSH environment: ${message}`, + detail: "Could not prepare the SSH environment.", + cause, }); } @@ -252,7 +254,8 @@ const capabilitiesLayer = Layer.effectContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - detail: `Could not disconnect the SSH environment: ${String(cause)}`, + detail: "Could not disconnect the SSH environment.", + cause, }), }); }),