authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-02 21:25:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 21:25:44-07:00
log22e9d35613305a04a28e96f83abc10c1bcf4aa8a
tree6fe9dcf59d57d7ab29f6adec329203cfd610d05d
parentc91e93c231e7ae6ec993f8496120ef04dd5fe24d
signature Signed by PGP key B5690EEEBB952194

fix(web): outdated servers can be updated even when the client can't connect (#15002)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

18 files changed, 784 insertions(+), 20 deletions(-)

apps/web/src/components/ServerUpdateAction.tsx+54-1
......@@ -10,7 +10,7 @@ import { type ComponentProps, useRef, useState } from "react";
1010import { requestConfirmDialog } from "~/confirmDialog";
1111import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
1212import { useEnvironmentSettings } from "~/hooks/useSettings";
13import { serverEnvironment } from "~/state/server";
13import { serverEnvironment, updateOutdatedServer } from "~/state/server";
1414import { useAtomCommand } from "~/state/use-atom-command";
1515import { manualServerUpdateCommand } from "~/versionSkew";
1616import { Button } from "./ui/button";
......@@ -290,3 +290,56 @@ export function ServerUpdateAction({
290290 </Button>
291291 );
292292}
293
294/**
295 * Updates a host too old for this client to connect to. Its version comes
296 * from the host descriptor because the host never delivers a server config.
297 */
298export function OutdatedServerUpdateAction({
299 environmentId,
300 serverLabel,
301 fromVersion,
302 targetVersion,
303 label = "Update",
304}: {
305 readonly environmentId: EnvironmentId;
306 readonly serverLabel: string;
307 readonly fromVersion: string | undefined;
308 readonly targetVersion: string;
309 readonly label?: string;
310}) {
311 const update = useAtomCommand(updateOutdatedServer, { reportFailure: false });
312 const handleUpdate = async () => {
313 if (pendingUpdateEnvironmentIds.has(environmentId)) return;
314 pendingUpdateEnvironmentIds.add(environmentId);
315 try {
316 const result = await update({
317 environmentId,
318 input: { targetVersion },
319 ...(fromVersion === undefined ? {} : { fromVersion }),
320 });
321 if (result._tag === "Failure") {
322 if (isAtomCommandInterrupted(result)) return;
323 throw squashAtomCommandFailure(result);
324 }
325 toastManager.add({
326 type: "success",
327 title: `${serverLabel} updated`,
328 description: `Reconnected on t3@${result.value.targetVersion}.`,
329 });
330 } catch (error) {
331 toastManager.add({
332 type: "error",
333 title: "Server update failed",
334 description: updateFailureMessage(error),
335 });
336 } finally {
337 pendingUpdateEnvironmentIds.delete(environmentId);
338 }
339 };
340 return (
341 <Button size="xs" variant="outline" onClick={() => void handleUpdate()}>
342 {label}
343 </Button>
344 );
345}
apps/web/src/components/settings/ConnectionsSettings.tsx+13
......@@ -165,11 +165,13 @@ import {
165165 usePrimaryEnvironment,
166166 useRelayEnvironmentDiscovery,
167167} from "~/state/environments";
168import { APP_VERSION } from "~/branding";
168169import { requestConfirmDialog } from "~/confirmDialog";
169170import { useAtomCommand } from "../../state/use-atom-command";
170171import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
171172import { ConnectionStatusDot } from "../ConnectionStatusDot";
172173import {
174 OutdatedServerUpdateAction,
173175 ServerUpdateAction,
174176 ServerUpdateProgress,
175177 ServerUpdatesAction,
......@@ -1598,6 +1600,17 @@ function SavedBackendListRow({
15981600 ) : null
15991601 }
16001602 >
1603 {unsupported &&
1604 environment.entry.serverUpdateRequired === true &&
1605 serverUpdateState.status !== "running" ? (
1606 <OutdatedServerUpdateAction
1607 environmentId={environmentId}
1608 serverLabel={`${environment.label} server`}
1609 fromVersion={lastDescriptor?.serverVersion}
1610 targetVersion={APP_VERSION}
1611 label={serverUpdateState.status === "failed" ? "Retry update" : "Update"}
1612 />
1613 ) : null}
16011614 {showUpdateAction ? (
16021615 <ServerUpdateAction
16031616 environmentId={environmentId}
apps/web/src/state/server.ts+3
......@@ -10,6 +10,7 @@ import {
1010 type ServerSettings,
1111} from "@t3tools/contracts";
1212import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";
13import { createOutdatedServerUpdateCommand } from "@t3tools/client-runtime/state/outdatedServerUpdate";
1314import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell";
1415import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings";
1516import * as Option from "effect/Option";
......@@ -32,6 +33,8 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt
3233 usageLimitSources: true,
3334 usageLimitsCommand: true,
3435});
36/** Updates a host whose protocol is too old for this client to connect to. */
37export const updateOutdatedServer = createOutdatedServerUpdateCommand(connectionAtomRuntime);
3538export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
3639 catalogValueAtom: environmentCatalog.catalogValueAtom,
3740 serverConfigValueAtom: serverEnvironment.configValueAtom,
packages/client-runtime/package.json+4
......@@ -219,6 +219,10 @@
219219 "types": "./src/state/server.ts",
220220 "default": "./src/state/server.ts"
221221 },
222 "./state/outdatedServerUpdate": {
223 "types": "./src/state/outdatedServerUpdate.ts",
224 "default": "./src/state/outdatedServerUpdate.ts"
225 },
222226 "./state/session": {
223227 "types": "./src/state/session.ts",
224228 "default": "./src/state/session.ts"
packages/client-runtime/src/connection/catalog.ts+2
......@@ -43,6 +43,8 @@ export interface ConnectionCatalogEntry {
4343 readonly enabled: boolean;
4444 /** Discovery rejection stays visible while the saved connection is switched off. */
4545 readonly unsupportedReason?: string;
46 /** The rejection came from an outdated host, which can still be updated remotely. */
47 readonly serverUpdateRequired?: boolean;
4648}
4749
4850export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()(
packages/client-runtime/src/connection/compatibility.test.ts+24
......@@ -50,5 +50,29 @@ describe("orchestration protocol compatibility", () => {
5050 );
5151 expect(error).toMatchObject({ reason: "unsupported" });
5252 expect(error?.message).toContain("This client is not supported");
53 expect(error).not.toHaveProperty("serverUpdateRequired");
54 });
55
56 it("offers a remote update only for an older host that can update itself", () => {
57 const older = descriptor(ORCHESTRATION_PROTOCOL_VERSION - 1);
58 const withCapabilities = (capabilities: ExecutionEnvironmentDescriptor["capabilities"]) =>
59 orchestrationProtocolCompatibilityError({ ...older, capabilities });
60
61 expect(
62 withCapabilities({ repositoryIdentity: true, serverSelfUpdate: "boot-service" }),
63 ).toMatchObject({ serverUpdateRequired: true });
64 expect(withCapabilities({ repositoryIdentity: true })).not.toHaveProperty(
65 "serverUpdateRequired",
66 );
67 expect(
68 withCapabilities({ repositoryIdentity: true, serverSelfUpdate: "desktop-managed" }),
69 ).not.toHaveProperty("serverUpdateRequired");
70 expect(
71 withCapabilities({
72 repositoryIdentity: true,
73 serverSelfUpdate: "desktop-managed",
74 desktopAppUpdate: true,
75 }),
76 ).toMatchObject({ serverUpdateRequired: true });
5377 });
5478});
packages/client-runtime/src/connection/compatibility.ts+19-7
......@@ -14,13 +14,25 @@ export function orchestrationProtocolCompatibilityError(
1414 if (serverProtocolVersion === ORCHESTRATION_PROTOCOL_VERSION) {
1515 return null;
1616 }
17 return new ConnectionBlockedError({
18 reason: "unsupported",
19 detail:
20 serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION
21 ? `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.`
22 : `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`,
23 });
17 return serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION
18 ? new ConnectionBlockedError({
19 reason: "unsupported",
20 detail: `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.`,
21 })
22 : new ConnectionBlockedError({
23 reason: "unsupported",
24 detail: `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`,
25 ...(canSelfUpdate(descriptor) ? { serverUpdateRequired: true } : {}),
26 });
27}
28
29/** Whether this client can drive the host's update remotely. */
30function canSelfUpdate(descriptor: ExecutionEnvironmentDescriptor): boolean {
31 const { serverSelfUpdate, desktopAppUpdate } = descriptor.capabilities;
32 return (
33 serverSelfUpdate !== undefined &&
34 (serverSelfUpdate !== "desktop-managed" || desktopAppUpdate === true)
35 );
2436}
2537
2638export function appendOrchestrationProtocol(socketUrl: string): string {
packages/client-runtime/src/connection/index.ts+2
......@@ -16,3 +16,5 @@ export * as EnvironmentSupervisor from "./supervisor.ts";
1616export * as Wakeups from "./wakeups.ts";
1717
1818export { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
19// Flat so consumers' inferred command types can name it.
20export { OutdatedHostUpdateError } from "./outdatedHostUpdate.ts";
packages/client-runtime/src/connection/layer.ts+2
......@@ -75,6 +75,8 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) {
7575 registryLayer,
7676 RelayEnvironmentDiscovery.layer,
7777 onboardingLayer,
78 // Exposed for updating hosts too old to connect through the driver.
79 ConnectionResolver.layer,
7880 );
7981 const connectionStartupLayer = Layer.effectDiscard(
8082 Effect.gen(function* () {
packages/client-runtime/src/connection/model.ts+2
......@@ -94,6 +94,8 @@ export class ConnectionBlockedError extends Schema.TaggedError<ConnectionBlocked
9494 reason: ConnectionBlockedReason,
9595 detail: Schema.String,
9696 traceId: Schema.optionalKey(Schema.String),
97 /** The host speaks an older orchestration protocol; updating it restores the connection. */
98 serverUpdateRequired: Schema.optionalKey(Schema.Boolean),
9799 },
98100) {
99101 override get message(): string {
packages/client-runtime/src/connection/onboarding.test.ts+51-1
......@@ -32,7 +32,11 @@ const CLIENT_PRESENTATION_LAYER = Layer.succeed(
3232
3333function pairingHttpLayer(
3434 calls: Array<{ readonly url: string; readonly init: RequestInit }>,
35 options?: { readonly failDescriptor?: boolean; readonly protocolVersion?: number },
35 options?: {
36 readonly failDescriptor?: boolean;
37 readonly protocolVersion?: number;
38 readonly selfUpdate?: boolean;
39 },
3640) {
3741 const fetchFn = ((input, init = {}) => {
3842 const url = String(input);
......@@ -56,6 +60,7 @@ function pairingHttpLayer(
5660 orchestrationProtocolVersion: options?.protocolVersion ?? ORCHESTRATION_PROTOCOL_VERSION,
5761 capabilities: {
5862 repositoryIdentity: true,
63 ...(options?.selfUpdate === true ? { serverSelfUpdate: "boot-service" } : {}),
5964 },
6065 }),
6166 );
......@@ -145,6 +150,51 @@ describe("connection onboarding", () => {
145150 }),
146151 );
147152
153 it.effect("pairs an outdated server so it can be updated from this client", () =>
154 Effect.gen(function* () {
155 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
156 const registration = yield* preparePairingRegistration({
157 host: "remote.example.test",
158 pairingCode: "pairing-token",
159 }).pipe(
160 Effect.provide(
161 Layer.mergeAll(
162 CLIENT_PRESENTATION_LAYER,
163 pairingHttpLayer(calls, {
164 protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1,
165 selfUpdate: true,
166 }),
167 ),
168 ),
169 );
170 expect(registration.target.environmentId).toBe("environment-paired");
171 expect(calls.map((call) => call.url)).toContain("https://remote.example.test/oauth/token");
172 }),
173 );
174
175 it.effect("refuses an outdated server that cannot update itself", () =>
176 Effect.gen(function* () {
177 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
178 const error = yield* preparePairingRegistration({
179 host: "remote.example.test",
180 pairingCode: "pairing-token",
181 }).pipe(
182 Effect.provide(
183 Layer.mergeAll(
184 CLIENT_PRESENTATION_LAYER,
185 pairingHttpLayer(calls, { protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1 }),
186 ),
187 ),
188 Effect.flip,
189 );
190 expect(error).toMatchObject({ reason: "unsupported" });
191 expect(error).not.toHaveProperty("serverUpdateRequired");
192 expect(calls.map((call) => call.url)).toEqual([
193 "https://remote.example.test/.well-known/t3/environment",
194 ]);
195 }),
196 );
197
148198 it.effect("does not consume a pairing credential when descriptor discovery fails", () =>
149199 Effect.gen(function* () {
150200 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
packages/client-runtime/src/connection/onboarding.ts+4-1
......@@ -93,7 +93,10 @@ export const preparePairingRegistration = Effect.fn(
9393 httpBaseUrl: target.httpBaseUrl,
9494 }).pipe(Effect.mapError(mapRemoteEnvironmentError));
9595 const compatibilityError = orchestrationProtocolCompatibilityError(descriptor);
96 if (compatibilityError !== null) return yield* compatibilityError;
96 // An outdated server is still saved so it can be updated from this client.
97 if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) {
98 return yield* compatibilityError;
99 }
97100 const access = yield* bootstrapRemoteBearerSession({
98101 httpBaseUrl: target.httpBaseUrl,
99102 credential: target.credential,
packages/client-runtime/src/connection/outdatedHostUpdate.test.ts created+278
......@@ -0,0 +1,278 @@
1import {
2 EnvironmentId,
3 ORCHESTRATION_PROTOCOL_VERSION,
4 ExecutionEnvironmentDescriptor,
5 WS_METHODS,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12import * as SubscriptionRef from "effect/SubscriptionRef";
13import * as HttpClient from "effect/unstable/http/HttpClient";
14import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
15import * as Socket from "effect/unstable/socket/Socket";
16
17import type { ConnectionCatalogEntry } from "./catalog.ts";
18import { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
19import { PrimaryConnectionTarget } from "./model.ts";
20import { updateOutdatedHost } from "./outdatedHostUpdate.ts";
21import * as EnvironmentRegistry from "./registry.ts";
22import * as ConnectionResolver from "./resolver.ts";
23import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
24
25const TARGET = new PrimaryConnectionTarget({
26 environmentId: EnvironmentId.make("environment-old"),
27 label: "Build Mac",
28 httpBaseUrl: "https://build.example.test",
29 wsBaseUrl: "wss://build.example.test",
30});
31
32const descriptor = (protocol: number | undefined, serverVersion: string) =>
33 ({
34 environmentId: TARGET.environmentId,
35 label: TARGET.label,
36 platform: { os: "darwin", arch: "arm64" },
37 serverVersion,
38 ...(protocol === undefined ? {} : { orchestrationProtocolVersion: protocol }),
39 capabilities: { repositoryIdentity: true, serverSelfUpdate: "boot-service" },
40 }) satisfies ExecutionEnvironmentDescriptor;
41
42const RpcRequest = Schema.TaggedStruct("Request", {
43 id: Schema.Union([Schema.String, Schema.Number]),
44 payload: Schema.Unknown,
45 tag: Schema.String,
46});
47const isRpcRequest = Schema.is(RpcRequest);
48const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
49const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
50const encodeDescriptor = Schema.encodeSync(Schema.fromJsonString(ExecutionEnvironmentDescriptor));
51
52type Listener = (event: { readonly type: string; readonly data?: unknown }) => void;
53
54/** Answers update RPCs the way a protocol-1 server does. */
55class OutdatedHostSocket {
56 static readonly OPEN = 1;
57 readyState = 0;
58 readonly requests: Array<typeof RpcRequest.Type> = [];
59 private readonly listeners = new Map<string, Set<Listener>>();
60
61 readonly url: string;
62 private readonly onUpdate: () => void;
63
64 constructor(url: string, onUpdate: () => void) {
65 this.url = url;
66 this.onUpdate = onUpdate;
67 queueMicrotask(() => {
68 this.readyState = OutdatedHostSocket.OPEN;
69 this.emit({ type: "open" });
70 });
71 }
72
73 addEventListener(type: string, listener: Listener) {
74 const listeners = this.listeners.get(type) ?? new Set<Listener>();
75 listeners.add(listener);
76 this.listeners.set(type, listeners);
77 }
78
79 removeEventListener(type: string, listener: Listener) {
80 this.listeners.get(type)?.delete(listener);
81 }
82
83 send(data: string) {
84 const message = decodeJson(data);
85 if (!isRpcRequest(message)) return;
86 this.requests.push(message);
87 if (message.tag !== WS_METHODS.serverUpdateServer) return;
88 this.onUpdate();
89 queueMicrotask(() =>
90 this.emit({
91 type: "message",
92 data: encodeJson({
93 _tag: "Exit",
94 requestId: message.id,
95 exit: {
96 _tag: "Success",
97 value: { targetVersion: "0.0.46", method: "boot-service", updateId: "update-1" },
98 },
99 }),
100 }),
101 );
102 }
103
104 close() {
105 this.readyState = 3;
106 this.emit({ type: "close" });
107 }
108
109 private emit(event: { readonly type: string; readonly data?: unknown }) {
110 for (const listener of this.listeners.get(event.type) ?? []) listener(event);
111 }
112}
113
114describe("updateOutdatedHost", () => {
115 it.effect("updates a protocol-1 host over a bare socket, then switches it back on", () =>
116 Effect.gen(function* () {
117 const blocked = orchestrationProtocolCompatibilityError(descriptor(undefined, "0.0.45"));
118 expect(blocked).toMatchObject({ serverUpdateRequired: true });
119
120 const sockets: Array<OutdatedHostSocket> = [];
121 let served: ExecutionEnvironmentDescriptor = descriptor(undefined, "0.0.45");
122 const entries = yield* SubscriptionRef.make<
123 ReadonlyMap<EnvironmentId, ConnectionCatalogEntry>
124 >(
125 new Map([
126 [
127 TARGET.environmentId,
128 {
129 target: TARGET,
130 profile: Option.none(),
131 enabled: false,
132 unsupportedReason: blocked?.message ?? "",
133 serverUpdateRequired: true,
134 },
135 ],
136 ]),
137 );
138 const calls: Array<string> = [];
139 const registry = EnvironmentRegistry.EnvironmentRegistry.of({
140 entries,
141 setCompatibility: (_environmentId: EnvironmentId, error: unknown) =>
142 Effect.sync(() => calls.push(`compatibility:${error === null ? "clear" : "block"}`)),
143 setEnabled: (_environmentId: EnvironmentId, enabled: boolean) =>
144 Effect.sync(() => calls.push(`enabled:${enabled}`)),
145 } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]);
146 const resolver = ConnectionResolver.ConnectionResolver.of({
147 prepare: () => Effect.die(new Error("The update must bypass the protocol gate.")),
148 prepareForUpdate: () =>
149 Effect.sync(() => served).pipe(
150 Effect.map((current) => ({
151 descriptor: current,
152 prepared: {
153 environmentId: TARGET.environmentId,
154 label: TARGET.label,
155 httpBaseUrl: TARGET.httpBaseUrl,
156 socketUrl: "wss://build.example.test/ws?wsTicket=ticket",
157 httpAuthorization: null,
158 target: TARGET,
159 },
160 })),
161 ),
162 });
163 const httpClient = HttpClient.make((request) =>
164 Effect.sync(() =>
165 HttpClientResponse.fromWeb(request, new Response(encodeDescriptor(served))),
166 ),
167 );
168
169 const result = yield* updateOutdatedHost(
170 TARGET.environmentId,
171 { targetVersion: "0.0.46" },
172 () => Effect.void,
173 ).pipe(
174 Effect.provide(
175 Layer.mergeAll(
176 Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registry),
177 Layer.succeed(ConnectionResolver.ConnectionResolver, resolver),
178 Layer.succeed(
179 RelayEnvironmentDiscovery.RelayEnvironmentDiscovery,
180 RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({
181 state: yield* SubscriptionRef.make(
182 RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE,
183 ),
184 refresh: Effect.void,
185 }),
186 ),
187 Layer.succeed(HttpClient.HttpClient, httpClient),
188 Layer.succeed(Socket.WebSocketConstructor, (url) => {
189 // The host relaunches on a compatible protocol once the update lands.
190 const socket = new OutdatedHostSocket(url, () => {
191 served = descriptor(ORCHESTRATION_PROTOCOL_VERSION, "0.0.46");
192 });
193 sockets.push(socket);
194 return socket as unknown as globalThis.WebSocket;
195 }),
196 ),
197 ),
198 );
199
200 expect(sockets[0]?.url).not.toContain("orchestrationProtocol");
201 expect(sockets[0]?.requests.map((request) => request.tag)).toEqual([
202 WS_METHODS.serverUpdateServer,
203 ]);
204 expect(result.targetVersion).toBe("0.0.46");
205 expect(calls).toEqual(["compatibility:clear", "enabled:true"]);
206 }),
207 );
208
209 it.effect("refuses a host that cannot update itself without opening a socket", () =>
210 Effect.gen(function* () {
211 const manual = {
212 ...descriptor(undefined, "0.0.45"),
213 capabilities: { repositoryIdentity: true },
214 };
215 let opened = false;
216 const error = yield* Effect.flip(
217 updateOutdatedHost(TARGET.environmentId, { targetVersion: "0.0.46" }, () => Effect.void),
218 ).pipe(
219 Effect.provide(
220 Layer.mergeAll(
221 Layer.succeed(
222 EnvironmentRegistry.EnvironmentRegistry,
223 EnvironmentRegistry.EnvironmentRegistry.of({
224 entries: yield* SubscriptionRef.make<
225 ReadonlyMap<EnvironmentId, ConnectionCatalogEntry>
226 >(
227 new Map([
228 [
229 TARGET.environmentId,
230 { target: TARGET, profile: Option.none(), enabled: false },
231 ],
232 ]),
233 ),
234 } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]),
235 ),
236 Layer.succeed(
237 ConnectionResolver.ConnectionResolver,
238 ConnectionResolver.ConnectionResolver.of({
239 prepare: () => Effect.die(new Error("unused")),
240 prepareForUpdate: () =>
241 Effect.succeed({
242 descriptor: manual,
243 prepared: {
244 environmentId: TARGET.environmentId,
245 label: TARGET.label,
246 httpBaseUrl: TARGET.httpBaseUrl,
247 socketUrl: "wss://build.example.test/ws",
248 httpAuthorization: null,
249 target: TARGET,
250 },
251 }),
252 }),
253 ),
254 Layer.succeed(
255 RelayEnvironmentDiscovery.RelayEnvironmentDiscovery,
256 RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({
257 state: yield* SubscriptionRef.make(
258 RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE,
259 ),
260 refresh: Effect.void,
261 }),
262 ),
263 Layer.succeed(
264 HttpClient.HttpClient,
265 HttpClient.make(() => Effect.die(new Error("unused"))),
266 ),
267 Layer.succeed(Socket.WebSocketConstructor, () => {
268 opened = true;
269 throw new Error("unused");
270 }),
271 ),
272 ),
273 );
274 expect(error).toMatchObject({ _tag: "OutdatedHostUpdateError" });
275 expect(opened).toBe(false);
276 }),
277 );
278});
packages/client-runtime/src/connection/outdatedHostUpdate.ts created+190
......@@ -0,0 +1,190 @@
1import {
2 ORCHESTRATION_PROTOCOL_VERSION,
3 type EnvironmentId,
4 type ExecutionEnvironmentDescriptor,
5 type ServerSelfUpdateInput,
6 type ServerSelfUpdateResult,
7 WS_METHODS,
8} from "@t3tools/contracts";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as Ref from "effect/Ref";
14import * as Schedule from "effect/Schedule";
15import * as Schema from "effect/Schema";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18import * as HttpClient from "effect/unstable/http/HttpClient";
19import * as RpcClient from "effect/unstable/rpc/RpcClient";
20import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
21import * as Socket from "effect/unstable/socket/Socket";
22
23import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
24import { makeWsRpcProtocolClient } from "../rpc/protocol.ts";
25import { isLegacyUpdateHandoffLoss, resolveServerUpdateProgressResult } from "../state/server.ts";
26import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
27import * as ConnectionResolver from "./resolver.ts";
28import * as EnvironmentRegistry from "./registry.ts";
29
30// A v1 host restarting into v2 runs migrations before its descriptor answers again.
31const OUTDATED_HOST_RESTART_TIMEOUT = Duration.minutes(4);
32const SOCKET_OPEN_TIMEOUT = "15 seconds";
33
34export class OutdatedHostUpdateError extends Schema.TaggedError<OutdatedHostUpdateError>()(
35 "OutdatedHostUpdateError",
36 {
37 environmentId: Schema.String,
38 message: Schema.String,
39 },
40) {}
41
42export type OutdatedHostUpdateStage = "downloading" | "installing" | "resuming";
43
44/**
45 * Updates a host whose orchestration protocol is too old for this client.
46 *
47 * The normal session refuses to open against such a host, so this opens a
48 * bare socket and calls only the self-update RPCs, whose wire shape has not
49 * changed across protocol versions. Once the host relaunches on a compatible
50 * protocol the environment is switched back on and connects normally.
51 */
52export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutdatedHost")(
53 function* (
54 environmentId: EnvironmentId,
55 input: ServerSelfUpdateInput,
56 onStage: (stage: OutdatedHostUpdateStage) => Effect.Effect<void>,
57 ) {
58 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
59 const resolver = yield* ConnectionResolver.ConnectionResolver;
60 const webSocketConstructor = yield* Socket.WebSocketConstructor;
61 const httpClient = yield* HttpClient.HttpClient;
62 const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId);
63 if (entry === undefined) {
64 return yield* new EnvironmentRegistry.EnvironmentNotRegisteredError({ environmentId });
65 }
66 const { prepared, descriptor } = yield* resolver.prepareForUpdate(entry);
67 const capabilities = descriptor.capabilities;
68 if (
69 capabilities.serverSelfUpdate === undefined ||
70 (capabilities.serverSelfUpdate === "desktop-managed" &&
71 capabilities.desktopAppUpdate !== true)
72 ) {
73 return yield* new OutdatedHostUpdateError({
74 environmentId,
75 message: `Update T3 Code on ${descriptor.label} manually; it cannot update itself.`,
76 });
77 }
78
79 const result = yield* Effect.scoped(
80 Effect.gen(function* () {
81 const protocolContext = yield* Layer.build(
82 Layer.effect(
83 RpcClient.Protocol,
84 RpcClient.makeProtocolSocket({
85 retryTransientErrors: false,
86 retryPolicy: Schedule.recurs(0),
87 }),
88 ).pipe(
89 Layer.provide(
90 Layer.mergeAll(
91 Socket.layerWebSocket(prepared.socketUrl, {
92 openTimeout: SOCKET_OPEN_TIMEOUT,
93 }).pipe(
94 Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor)),
95 ),
96 RpcSerialization.layerJson,
97 ),
98 ),
99 ),
100 );
101 const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext));
102
103 const updateResult: ServerSelfUpdateResult =
104 capabilities.serverSelfUpdateProgress === true
105 ? yield* Effect.gen(function* () {
106 const terminal = yield* Ref.make(Option.none<ServerSelfUpdateResult>());
107 const streamExit = yield* client[WS_METHODS.serverUpdateServerWithProgress](
108 input,
109 ).pipe(
110 Stream.runForEach((event) =>
111 event.type === "complete"
112 ? Ref.set(terminal, Option.some(event.result))
113 : onStage(event.stage),
114 ),
115 Effect.exit,
116 );
117 return yield* resolveServerUpdateProgressResult(
118 input.targetVersion,
119 yield* Ref.get(terminal),
120 streamExit,
121 );
122 })
123 : yield* client[WS_METHODS.serverUpdateServer](input).pipe(
124 // Older servers can drop the socket before acknowledging the restart.
125 Effect.catchCauseIf(
126 (cause) =>
127 (capabilities.serverSelfUpdate === "boot-service" ||
128 capabilities.serverSelfUpdate === "respawn") &&
129 isLegacyUpdateHandoffLoss(cause),
130 () =>
131 Effect.succeed({
132 targetVersion: input.targetVersion,
133 method: capabilities.serverSelfUpdate as "boot-service" | "respawn",
134 } satisfies ServerSelfUpdateResult),
135 ),
136 );
137
138 if (
139 updateResult.method === "desktop-app" &&
140 updateResult.desktopUpdateToken !== undefined
141 ) {
142 // The commit relaunches the desktop app, so a dropped socket is success.
143 yield* client[WS_METHODS.serverCommitDesktopUpdate]({
144 requestId: updateResult.desktopUpdateToken,
145 }).pipe(
146 Effect.catchCauseIf(
147 (cause) => isLegacyUpdateHandoffLoss(cause),
148 () => Effect.void,
149 ),
150 );
151 }
152 return updateResult;
153 }),
154 );
155
156 yield* onStage("resuming");
157 const resumed = yield* fetchRemoteEnvironmentDescriptor({
158 httpBaseUrl: prepared.httpBaseUrl,
159 }).pipe(
160 Effect.provideService(HttpClient.HttpClient, httpClient),
161 Effect.option,
162 Effect.repeat({
163 schedule: Schedule.spaced(Duration.seconds(1)),
164 until: (current) => Option.exists(current, isCompatibleDescriptor),
165 }),
166 Effect.timeoutOption(OUTDATED_HOST_RESTART_TIMEOUT),
167 Effect.map(Option.flatten),
168 );
169 if (Option.isNone(resumed)) {
170 return yield* new OutdatedHostUpdateError({
171 environmentId,
172 message: `${descriptor.label} did not come back on a compatible T3 Code version.`,
173 });
174 }
175
176 // Discovery still holds the old relay descriptor and would re-block the
177 // environment from it, so replace that before clearing the block.
178 if (entry.target._tag === "RelayConnectionTarget") {
179 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
180 yield* discovery.refresh;
181 }
182 yield* registry.setCompatibility(environmentId, null);
183 yield* registry.setEnabled(environmentId, true);
184 return { ...result, targetVersion: resumed.value.serverVersion };
185 },
186);
187
188function isCompatibleDescriptor(descriptor: ExecutionEnvironmentDescriptor): boolean {
189 return (descriptor.orchestrationProtocolVersion ?? 1) === ORCHESTRATION_PROTOCOL_VERSION;
190}
packages/client-runtime/src/connection/registry.ts+31-5
......@@ -42,6 +42,17 @@ import {
4242
4343const isSshConnectionProfile = Schema.is(SshConnectionProfile);
4444
45function unsupportedState(
46 entry: ConnectionCatalogEntry,
47): Pick<ConnectionCatalogEntry, "unsupportedReason" | "serverUpdateRequired"> {
48 return {
49 ...(entry.unsupportedReason === undefined
50 ? {}
51 : { unsupportedReason: entry.unsupportedReason }),
52 ...(entry.serverUpdateRequired === true ? { serverUpdateRequired: true } : {}),
53 };
54}
55
4556export class EnvironmentNotRegisteredError extends Schema.TaggedError<EnvironmentNotRegisteredError>()(
4657 "EnvironmentNotRegisteredError",
4758 {
......@@ -455,7 +466,7 @@ export const make = Effect.gen(function* () {
455466 enabled: previous.enabled,
456467 ...(previous.unsupportedReason !== undefined &&
457468 gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered)
458 ? { unsupportedReason: previous.unsupportedReason }
469 ? unsupportedState(previous)
459470 : {}),
460471 };
461472 if (
......@@ -494,7 +505,7 @@ export const make = Effect.gen(function* () {
494505 const entry: ConnectionCatalogEntry =
495506 previous?.unsupportedReason !== undefined &&
496507 gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered)
497 ? { ...registered, enabled: false, unsupportedReason: previous.unsupportedReason }
508 ? { ...registered, enabled: false, ...unsupportedState(previous) }
498509 : registered;
499510 const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get(
500511 target.environmentId,
......@@ -844,11 +855,26 @@ export const make = Effect.gen(function* () {
844855 environmentId,
845856 Effect.gen(function* () {
846857 const entry = (yield* SubscriptionRef.get(entries)).get(environmentId);
847 if (entry === undefined || entry.unsupportedReason === (error?.message ?? undefined))
858 if (
859 entry === undefined ||
860 (entry.unsupportedReason === (error?.message ?? undefined) &&
861 entry.serverUpdateRequired === (error?.serverUpdateRequired ?? undefined))
862 )
848863 return;
849 const { unsupportedReason: _previousReason, ...rest } = entry;
864 const {
865 unsupportedReason: _previousReason,
866 serverUpdateRequired: _previousUpdateRequired,
867 ...rest
868 } = entry;
850869 const next: ConnectionCatalogEntry =
851 error === null ? rest : { ...rest, enabled: false, unsupportedReason: error.message };
870 error === null
871 ? rest
872 : {
873 ...rest,
874 enabled: false,
875 unsupportedReason: error.message,
876 ...(error.serverUpdateRequired === true ? { serverUpdateRequired: true } : {}),
877 };
852878 if (
853879 error !== null &&
854880 entry.enabled &&
packages/client-runtime/src/connection/resolver.ts+24-3
......@@ -1,4 +1,7 @@
1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
1import type {
2 AuthClientPresentationMetadata,
3 ExecutionEnvironmentDescriptor,
4} from "@t3tools/contracts";
25import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
36import * as Context from "effect/Context";
47import * as Effect from "effect/Effect";
......@@ -49,6 +52,17 @@ export class ConnectionResolver extends Context.Service<
4952 readonly prepare: (
5053 entry: ConnectionCatalogEntry,
5154 ) => Effect.Effect<PreparedConnection, ConnectionAttemptError>;
55 /**
56 * Authorizes a socket without the orchestration protocol gate, for hosts
57 * too old to connect normally. Only update RPCs may run over it.
58 */
59 readonly prepareForUpdate: (entry: ConnectionCatalogEntry) => Effect.Effect<
60 {
61 readonly prepared: PreparedConnection;
62 readonly descriptor: ExecutionEnvironmentDescriptor;
63 },
64 ConnectionAttemptError
65 >;
5266 }
5367>()("@t3tools/client-runtime/connection/resolver/ConnectionResolver") {}
5468
......@@ -245,7 +259,7 @@ export const make = Effect.gen(function* () {
245259 const ssh = yield* makeSshBroker();
246260 const httpClient = yield* HttpClient.HttpClient;
247261
248 const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* (
262 const authorize = Effect.fn("clientRuntime.connection.broker.authorize")(function* (
249263 entry: ConnectionCatalogEntry,
250264 ) {
251265 const target: ConnectionTarget = entry.target;
......@@ -277,6 +291,13 @@ export const make = Effect.gen(function* () {
277291 actual: descriptor.environmentId,
278292 });
279293 }
294 return { prepared, descriptor };
295 });
296
297 const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* (
298 entry: ConnectionCatalogEntry,
299 ) {
300 const { prepared, descriptor } = yield* authorize(entry);
280301 const compatibilityError = orchestrationProtocolCompatibilityError(descriptor);
281302 if (compatibilityError !== null) {
282303 return yield* compatibilityError;
......@@ -287,7 +308,7 @@ export const make = Effect.gen(function* () {
287308 };
288309 });
289310
290 return ConnectionResolver.of({ prepare });
311 return ConnectionResolver.of({ prepare, prepareForUpdate: authorize });
291312});
292313
293314export const layer = Layer.effect(ConnectionResolver, make);
packages/client-runtime/src/state/outdatedServerUpdate.ts created+78
......@@ -0,0 +1,78 @@
1import type { EnvironmentId, ServerSelfUpdateInput } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import type * as HttpClient from "effect/unstable/http/HttpClient";
6import type { Atom } from "effect/unstable/reactivity";
7import type * as Socket from "effect/unstable/socket/Socket";
8
9import { updateOutdatedHost } from "../connection/outdatedHostUpdate.ts";
10import type * as ConnectionResolver from "../connection/resolver.ts";
11import type * as EnvironmentRegistry from "../connection/registry.ts";
12import type * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
13import { createAtomCommandScheduler, createRuntimeCommand } from "./runtime.ts";
14import {
15 serverUpdateFailureMessage,
16 serverUpdateStateAtom,
17 type ServerUpdateStage,
18} from "./server.ts";
19
20export interface OutdatedServerUpdateTarget {
21 readonly environmentId: EnvironmentId;
22 readonly input: ServerSelfUpdateInput;
23 /** From the host descriptor when known; an outdated host never delivers a server config. */
24 readonly fromVersion?: string;
25}
26
27/**
28 * Updates a host too old for this client to connect to. Progress lands in the
29 * same per-environment update state as a normal server update.
30 */
31export function createOutdatedServerUpdateCommand<E>(
32 runtime: Atom.AtomRuntime<
33 | EnvironmentRegistry.EnvironmentRegistry
34 | ConnectionResolver.ConnectionResolver
35 | RelayEnvironmentDiscovery.RelayEnvironmentDiscovery
36 | Socket.WebSocketConstructor
37 | HttpClient.HttpClient,
38 E
39 >,
40) {
41 return createRuntimeCommand(runtime, {
42 label: "environment-data:server:update-outdated-server",
43 scheduler: createAtomCommandScheduler(),
44 concurrency: {
45 mode: "singleFlight",
46 key: ({ environmentId }: OutdatedServerUpdateTarget) => environmentId,
47 },
48 execute: (target: OutdatedServerUpdateTarget, atomRegistry) => {
49 const stateAtom = serverUpdateStateAtom(target.environmentId);
50 const targetVersion = target.input.targetVersion;
51 const fromVersion = target.fromVersion ?? targetVersion;
52 let currentStage: ServerUpdateStage = "downloading";
53 const setStage = (stage: ServerUpdateStage) =>
54 Effect.sync(() => {
55 currentStage = stage;
56 atomRegistry.set(stateAtom, { status: "running", stage, fromVersion, targetVersion });
57 });
58 return setStage(currentStage).pipe(
59 Effect.andThen(updateOutdatedHost(target.environmentId, target.input, setStage)),
60 Effect.onExit((exit) =>
61 Effect.sync(() => {
62 if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) {
63 atomRegistry.set(stateAtom, { status: "idle" });
64 return;
65 }
66 atomRegistry.set(stateAtom, {
67 status: "failed",
68 stage: currentStage,
69 fromVersion,
70 targetVersion,
71 message: serverUpdateFailureMessage(Cause.squash(exit.cause)),
72 });
73 }),
74 ),
75 );
76 },
77 });
78}
packages/client-runtime/src/state/server.ts+3-2
......@@ -84,7 +84,8 @@ const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" };
8484const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make<ServerUpdateState>(IDLE_SERVER_UPDATE_STATE).pipe(
8585 Atom.withLabel("environment-data:server:update-state:empty"),
8686);
87const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) =>
87/** Shared with the outdated-host update, which reports through the same state. */
88export const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) =>
8889 Atom.make<ServerUpdateState>(IDLE_SERVER_UPDATE_STATE).pipe(
8990 Atom.withLabel(`environment-data:server:update-state:${environmentId}`),
9091 ),
......@@ -307,7 +308,7 @@ export function serverUpdateStateForServerVersion(
307308 : IDLE_SERVER_UPDATE_STATE;
308309}
309310
310function serverUpdateFailureMessage(error: unknown): string {
311export function serverUpdateFailureMessage(error: unknown): string {
311312 return error instanceof Error ? error.message : "Server update failed.";
312313}
313314