Skip to content
Open
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
17 changes: 9 additions & 8 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2016,15 +2016,16 @@ 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),
],
() =>
[
...(primaryEnvironment ? [primaryEnvironment] : []),
...savedEnvironments.filter((environment) => environment.entry.enabled),
].filter((environment) => environment.connection.phase !== "unsupported"),
[primaryEnvironment, savedEnvironments],
);
const savedDesktopSshEnvironmentKeys = useMemo(() => {
Expand Down
7 changes: 5 additions & 2 deletions packages/client-runtime/src/connection/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,12 @@ export interface ConnectionCatalogEntry {
readonly target: ConnectionTarget;
readonly profile: Option.Option<ConnectionProfile>;
readonly alternateRoutes?: ReadonlyArray<ConnectionRoute>;
/** 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;
Expand Down
95 changes: 86 additions & 9 deletions packages/client-runtime/src/connection/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,15 +959,15 @@ 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");
}).pipe(Effect.provide(harness.layer), Effect.scoped);
}),
);

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],
Expand Down Expand Up @@ -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",
Expand All @@ -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]);
Expand Down Expand Up @@ -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({
Expand Down
23 changes: 12 additions & 11 deletions packages/client-runtime/src/connection/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>()(
"EnvironmentNotRegisteredError",
{
Expand Down Expand Up @@ -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) => {
Expand All @@ -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,
}),
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 3 additions & 3 deletions packages/client-runtime/src/state/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EnvironmentIdType> {
for (const [environmentId, entry] of catalog.entries) {
if (entry.enabled) {
if (entry.enabled && entry.unsupportedReason === undefined) {
yield environmentId;
}
}
Expand Down
Loading