From 50edad545361422b1e925457144b5bf7a5550f3a Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Sat, 3 Oct 2026 07:50:07 +0200 Subject: [PATCH 1/2] fix(client-runtime): incompatible hosts no longer switch environments off for good A protocol mismatch persisted the environment into the user's switched-off list, and nothing turned it back on once the host was updated. Treat the mismatch as runtime state instead: it pauses the connection without touching the user's on/off choice, and an environment the user left on reconnects when a fresh check finds the host compatible. Fixes #15051 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../settings/ConnectionsSettings.tsx | 13 ++- .../client-runtime/src/connection/catalog.ts | 7 +- .../src/connection/registry.test.ts | 95 +++++++++++++++++-- .../client-runtime/src/connection/registry.ts | 23 ++--- .../client-runtime/src/state/connections.ts | 6 +- 5 files changed, 114 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 42084ab8d5a4..83550ec5793f 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -2016,14 +2016,17 @@ export function ConnectionsSettings() { }), [savedServerUpdateStates], ); - // Switched-off machines never receive threads, so they stay out of the - // load balancing and GitHub sharing lists. The WSL backend has no row in - // the Environments list but does take threads, so it stays in here. This - // machine leads the list. + // Switched-off and unsupported machines never receive threads, so they stay + // out of the load balancing and GitHub sharing lists. The WSL backend has no + // row in the Environments list but does take threads, so it stays in here. + // This machine leads the list. const loadBalancingEnvironments = useMemo( () => [ ...(primaryEnvironment ? [primaryEnvironment] : []), - ...savedEnvironments.filter((environment) => environment.entry.enabled), + ...savedEnvironments.filter( + (environment) => + environment.entry.enabled && environment.connection.phase !== "unsupported", + ), ], [primaryEnvironment, savedEnvironments], ); diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index 88896e78a895..f750da750f31 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -62,9 +62,12 @@ export interface ConnectionCatalogEntry { readonly target: ConnectionTarget; readonly profile: Option.Option; readonly alternateRoutes?: ReadonlyArray; - /** False when the user switched the environment off: saved, but never connects. */ + /** The user's persisted choice. False means saved, but never connects. */ readonly enabled: boolean; - /** Discovery rejection stays visible while the saved connection is switched off. */ + /** + * Runtime protocol rejection, never persisted. Pauses the connection without + * changing `enabled`, so it reconnects once the host is compatible again. + */ readonly unsupportedReason?: string; /** The rejection came from an outdated host, which can still be updated remotely. */ readonly serverUpdateRequired?: boolean; diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 12296df81a30..ad5070b6999a 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -959,7 +959,7 @@ describe("EnvironmentRegistry", () => { yield* Deferred.await(relayChecked); const entries = yield* SubscriptionRef.get(registry.entries); - expect(entries.get(RELAY_TARGET.environmentId)).toMatchObject({ enabled: false }); + expect(entries.get(RELAY_TARGET.environmentId)?.unsupportedReason).toBeDefined(); expect(entries.get(TARGET.environmentId)).toMatchObject({ enabled: true }); expect(entries.get(TARGET.environmentId)?.unsupportedReason).toBeUndefined(); expect((yield* registry.state(TARGET.environmentId)).phase).toBe("connected"); @@ -967,7 +967,7 @@ describe("EnvironmentRegistry", () => { }), ); - it.effect("discovery keeps unsupported environments off until compatibility changes", () => + it.effect("a switched-off environment stays off after its compatibility block clears", () => Effect.gen(function* () { const harness = yield* makeHarness([RELAY_TARGET], [], [], { initialDisabled: [RELAY_TARGET.environmentId], @@ -1002,7 +1002,7 @@ describe("EnvironmentRegistry", () => { }), ); - it.effect("a socket preflight rejection persists the connection as switched off", () => + it.effect("a socket preflight rejection pauses the connection without switching it off", () => Effect.gen(function* () { const error = new ConnectionBlockedError({ reason: "unsupported", @@ -1013,20 +1013,97 @@ describe("EnvironmentRegistry", () => { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.start; yield* SubscriptionRef.changes(registry.entries).pipe( - Stream.filter((entries) => entries.get(RELAY_TARGET.environmentId)?.enabled === false), + Stream.filter( + (entries) => entries.get(RELAY_TARGET.environmentId)?.unsupportedReason !== undefined, + ), Stream.take(1), Stream.runDrain, ); - expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe(true); + expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe( + false, + ); expect( - (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId) - ?.unsupportedReason, - ).toBe(error.message); + (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId), + ).toMatchObject({ enabled: true, unsupportedReason: error.message }); + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => !state.desired && state.phase === "available", + ); expect(yield* Ref.get(harness.sessions)).toHaveLength(0); }).pipe(Effect.provide(harness.layer)); }), ); + it.effect("an enabled environment reconnects once its compatibility block clears", () => + Effect.gen(function* () { + const harness = yield* makeHarness([RELAY_TARGET]); + const error = new ConnectionBlockedError({ + reason: "unsupported", + detail: "Use a compatible client.", + }); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + yield* registry.setCompatibility(RELAY_TARGET.environmentId, error); + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "available", + ); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId), + ).toMatchObject({ enabled: true, unsupportedReason: error.message }); + expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe( + false, + ); + + yield* registry.setCompatibility(RELAY_TARGET.environmentId, null); + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + expect(yield* Ref.get(harness.sessions)).toHaveLength(2); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("a blocked environment comes back enabled after a relaunch", () => + Effect.gen(function* () { + const harness = yield* makeHarness([RELAY_TARGET]); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* registry.setCompatibility( + RELAY_TARGET.environmentId, + new ConnectionBlockedError({ reason: "unsupported", detail: "Use a compatible client." }), + ); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const entry = (yield* SubscriptionRef.get(registry.entries)).get( + RELAY_TARGET.environmentId, + ); + expect(entry?.enabled).toBe(true); + expect(entry?.unsupportedReason).toBeUndefined(); + yield* registry.start; + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("switching an environment off disconnects it and persists the flag", () => Effect.gen(function* () { const harness = yield* makeHarness([RELAY_TARGET]); @@ -1582,7 +1659,7 @@ describe("EnvironmentRegistry", () => { yield* registry.reconcilePlatform([registration]); expect( (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId), - ).toMatchObject({ enabled: false, unsupportedReason: error.message }); + ).toMatchObject({ enabled: true, unsupportedReason: error.message }); expect(yield* Ref.get(harness.sessions)).toHaveLength(1); yield* registry.registerPlatform( new PrimaryConnectionRegistration({ diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index d6ffac55c3db..32842e054444 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -68,6 +68,11 @@ function unsupportedState( }; } +/** The user wants it on and its host is not known to be incompatible. */ +function shouldConnect(entry: ConnectionCatalogEntry): boolean { + return entry.enabled && entry.unsupportedReason === undefined; +} + export class EnvironmentNotRegisteredError extends Schema.TaggedError()( "EnvironmentNotRegisteredError", { @@ -373,7 +378,7 @@ export const make = Effect.gen(function* () { Scope.provide(scope), Effect.onError(() => Scope.close(scope, Exit.void)), ); - if (entry.enabled) { + if (shouldConnect(entry)) { yield* supervisor.connect; } yield* SubscriptionRef.update(serviceScopes, (current) => { @@ -386,7 +391,7 @@ export const make = Effect.gen(function* () { state.phase === "blocked" && state.lastFailure?.reason === "unsupported" ? setCompatibility(environmentId, state.lastFailure).pipe( Effect.catch((error) => - Effect.logWarning("Could not disable an unsupported environment.", { + Effect.logWarning("Could not mark an environment unsupported.", { environmentId, error, }), @@ -623,7 +628,7 @@ export const make = Effect.gen(function* () { const entry: ConnectionCatalogEntry = previous?.unsupportedReason !== undefined && gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) - ? { ...registered, enabled: false, ...unsupportedState(previous) } + ? { ...registered, ...unsupportedState(previous) } : registered; const persisted = (yield* Ref.get(persistedEnvironmentIds)).has(target.environmentId); if ( @@ -1120,28 +1125,24 @@ export const make = Effect.gen(function* () { serverUpdateRequired: _previousUpdateRequired, ...rest } = entry; + // Compatibility is runtime state: it pauses the connection without + // touching the user's persisted on/off choice, so an environment the + // user left on reconnects once its host speaks our protocol again. const next: ConnectionCatalogEntry = error === null ? rest : { ...rest, - enabled: false, unsupportedReason: error.message, ...(error.serverUpdateRequired === true ? { serverUpdateRequired: true } : {}), }; - if ( - error !== null && - entry.enabled && - !(yield* Ref.get(platformEnvironmentIds)).has(environmentId) - ) { - yield* registrations.setEnabled(environmentId, false); - } const lease = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); if (lease !== undefined) { yield* SubscriptionRef.update(serviceScopes, (current) => new Map(current).set(environmentId, { ...lease, entry: next }), ); if (error !== null) yield* lease.supervisor.disconnect; + else if (shouldConnect(next)) yield* lease.supervisor.connect; } yield* SubscriptionRef.update(entries, (current) => new Map(current).set(environmentId, next), diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index d47761a2ce66..ac9e8af92a68 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -27,14 +27,14 @@ export interface EnvironmentCatalogState { /** * Environments that take part in the workspace: projects, threads, and shell - * summaries only come from these. Disabled environments stay in `entries` so - * Settings can list them and switch them back on. + * summaries only come from these. Disabled environments, and ones whose host + * this client cannot talk to, stay in `entries` so Settings can list them. */ export function* enabledEnvironmentIds( catalog: EnvironmentCatalogState, ): Generator { for (const [environmentId, entry] of catalog.entries) { - if (entry.enabled) { + if (entry.enabled && entry.unsupportedReason === undefined) { yield environmentId; } } From 1b14d4714a3c3ef3289d78af00cc4db23bd3e11c Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Mon, 5 Oct 2026 07:44:51 +0200 Subject: [PATCH 2/2] fix(web): an unsupported primary environment stays out of the routing lists too Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/settings/ConnectionsSettings.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 83550ec5793f..3cf4909382ad 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -2021,13 +2021,11 @@ export function ConnectionsSettings() { // row in the Environments list but does take threads, so it stays in here. // This machine leads the list. const loadBalancingEnvironments = useMemo( - () => [ - ...(primaryEnvironment ? [primaryEnvironment] : []), - ...savedEnvironments.filter( - (environment) => - environment.entry.enabled && environment.connection.phase !== "unsupported", - ), - ], + () => + [ + ...(primaryEnvironment ? [primaryEnvironment] : []), + ...savedEnvironments.filter((environment) => environment.entry.enabled), + ].filter((environment) => environment.connection.phase !== "unsupported"), [primaryEnvironment, savedEnvironments], ); const savedDesktopSshEnvironmentKeys = useMemo(() => {