authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 04:45:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 04:45:04-07:00
log197d3487116372ca2f16661f57d4e869fc3cb6c0
treeeeb4130265705a341ac320f2b416df7bb2c42118
parent5c9358acaa34f8619513bba1e6821f0fdec27f74
signature Signed by PGP key B5690EEEBB952194

fix(web): show server update progress through reconnect (#4903)


21 files changed, 1072 insertions(+), 364 deletions(-)

apps/server/src/auth/RpcAuthorization.ts+1
......@@ -32,6 +32,7 @@ export const RPC_REQUIRED_SCOPES = {
3232 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
3333 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
3434 [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
35 [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,
3536 [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope,
3637 [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,
3738 [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
apps/server/src/cloud/selfUpdate.test.ts+40-5
......@@ -26,6 +26,33 @@ import * as SelfUpdate from "./selfUpdate.ts";
2626
2727const NODE_PATH = "/usr/local/bin/node";
2828
29const eventuallyFileString = Effect.fn("test.eventuallyFileString")(function* (
30 filePath: string,
31 expected: string,
32) {
33 const fs = yield* FileSystem.FileSystem;
34 for (let iteration = 0; iteration < 1_000; iteration += 1) {
35 const contents = yield* fs.readFileString(filePath);
36 if (contents === expected) {
37 return;
38 }
39 // The rollback performs real filesystem I/O on a detached fiber, which
40 // advancing TestClock does not await.
41 yield* Effect.yieldNow;
42 }
43 return yield* Effect.die(new Error(`Expected file contents were not observed at ${filePath}.`));
44});
45
46const eventuallyTrue = Effect.fn("test.eventuallyTrue")(function* (predicate: () => boolean) {
47 for (let iteration = 0; iteration < 1_000; iteration += 1) {
48 if (predicate()) {
49 return;
50 }
51 yield* Effect.yieldNow;
52 }
53 return yield* Effect.die(new Error("Expected condition was not observed."));
54});
55
2956interface RecordedCommand {
3057 readonly command: string;
3158 readonly args: ReadonlyArray<string>;
......@@ -457,8 +484,12 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {
457484 it.effect("installs, preflights, and respawns a foreground server", () =>
458485 Effect.gen(function* () {
459486 const context = yield* makeContext();
460 const result = yield* context.service.update({ targetVersion: "0.0.29" });
487 const progress: Array<string> = [];
488 const result = yield* context.service.update({ targetVersion: "0.0.29" }, (stage) =>
489 Effect.sync(() => progress.push(stage)),
490 );
461491 assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" });
492 assert.deepEqual(progress, ["downloading", "installing"]);
462493 assert.lengthOf(context.spawns, 1);
463494
464495 const concurrentError = yield* context.service
......@@ -506,10 +537,12 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {
506537 assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`);
507538 assert.deepEqual(
508539 context.commands.map((entry) => entry.command),
509 ["npm", NODE_PATH, "systemctl", "systemctl"],
540 ["npm", NODE_PATH, "systemctl"],
510541 );
511542 assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]);
512543
544 // Restart waits until after the update acknowledgement can flush.
545 yield* TestClock.adjust(Duration.seconds(10));
513546 assert.deepEqual(context.commands[3], {
514547 command: "systemctl",
515548 args: ["--user", "restart", "t3code.service"],
......@@ -542,9 +575,11 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {
542575 );
543576 const previousUnit = yield* context.fs.readFileString(unitPath);
544577
545 const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
546 assert.include(first.reason, "Restarting the systemd boot service failed");
547 assert.equal(yield* context.fs.readFileString(unitPath), previousUnit);
578 const first = yield* context.service.update({ targetVersion: "0.0.29" });
579 assert.deepEqual(first, { targetVersion: "0.0.29", method: "boot-service" });
580 yield* TestClock.adjust(Duration.seconds(10));
581 yield* eventuallyFileString(unitPath, previousUnit);
582 yield* eventuallyTrue(() => context.commands.at(-1)?.args[1] === "daemon-reload");
548583 assert.deepEqual(
549584 context.commands.slice(-2).map((entry) => entry.args),
550585 [
apps/server/src/cloud/selfUpdate.ts+41-30
......@@ -6,6 +6,7 @@ import {
66 ServerSelfUpdateError,
77 type ServerSelfUpdateCapability,
88 type ServerSelfUpdateInput,
9 type ServerSelfUpdateProgressStage,
910 type ServerSelfUpdateResult,
1011} from "@t3tools/contracts";
1112import {
......@@ -147,6 +148,7 @@ export class ServerSelfUpdate extends Context.Service<
147148 {
148149 readonly update: (
149150 input: ServerSelfUpdateInput,
151 reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void, never, never>,
150152 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
151153 }
152154>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
......@@ -227,7 +229,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
227229
228230 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
229231 "cloud.server_self_update.update",
230 )(function* (input) {
232 )(function* (input, reportProgress = () => Effect.void) {
231233 if (capability === "desktop-managed") {
232234 return yield* failWith(
233235 "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",
......@@ -250,6 +252,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
250252 }
251253
252254 return yield* Effect.gen(function* () {
255 yield* reportProgress("downloading");
253256 const runtimePaths = yield* ensurePinnedRuntimeInstalled({
254257 baseDir: serverConfig.baseDir,
255258 version: targetVersion,
......@@ -260,6 +263,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
260263 Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)),
261264 );
262265
266 yield* reportProgress("installing");
263267 // A broken artifact (failed native build, incompatible node) must be
264268 // caught while the current server is still alive to report it.
265269 const preflight = yield* runner
......@@ -349,38 +353,45 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
349353 yield* Effect.logInfo("Server self-update installed; restarting boot service.", {
350354 targetVersion,
351355 });
352 // A successful systemd restart stops this process, so the RPC is
353 // interrupted and the reconnecting client observes the new version.
354 // A rejected restart returns while the old process is still alive;
355 // restore the previous unit and report that failure through the RPC.
356 yield* Effect.gen(function* () {
357 const restart = yield* runner
358 .run({
359 command: "systemctl",
360 args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE],
361 })
362 .pipe(
363 Effect.mapError((cause) =>
364 failWith("Could not restart the systemd boot service.", cause),
356 // Restart after the acknowledgement has had time to cross any relay
357 // hop. If systemd rejects the handoff, restore the previous unit while
358 // this process is still alive and log the failure for diagnostics.
359 yield* scheduleRestart(
360 Effect.gen(function* () {
361 const restart = yield* runner
362 .run({
363 command: "systemctl",
364 args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE],
365 })
366 .pipe(
367 Effect.mapError((cause) =>
368 failWith("Could not restart the systemd boot service.", cause),
369 ),
370 );
371 if (restart.code !== 0) {
372 return yield* failWith(
373 `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`,
374 );
375 }
376 }).pipe(
377 Effect.catch((restartError) =>
378 writeUnitAtomically(unitPath, previousUnit).pipe(
379 Effect.andThen(reloadSystemd()),
380 Effect.mapError((rollbackError) =>
381 failWith("Could not restore the previous systemd unit.", {
382 restartError,
383 rollbackError,
384 }),
385 ),
386 Effect.andThen(Effect.fail(restartError)),
365387 ),
366 );
367 if (restart.code !== 0) {
368 return yield* failWith(
369 `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`,
370 );
371 }
372 }).pipe(
373 Effect.catch((restartError) =>
374 writeUnitAtomically(unitPath, previousUnit).pipe(
375 Effect.andThen(reloadSystemd()),
376 Effect.mapError((rollbackError) =>
377 failWith("Could not restore the previous systemd unit.", {
378 restartError,
379 rollbackError,
380 }),
388 ),
389 Effect.catch((error) =>
390 Effect.logError("Server self-update could not restart the boot service.").pipe(
391 Effect.annotateLogs({ targetVersion, error: error.reason }),
381392 ),
382 Effect.andThen(Effect.fail(restartError)),
383393 ),
394 Effect.ensuring(Ref.set(inFlight, false)),
384395 ),
385396 );
386397 } else {
apps/server/src/environment/ServerEnvironment.ts+3
......@@ -144,6 +144,9 @@ export const make = Effect.gen(function* () {
144144 threadSnooze: true,
145145 threadTitleRegeneration: true,
146146 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
147 ...(serverSelfUpdate === "boot-service" || serverSelfUpdate === "respawn"
148 ? { serverSelfUpdateProgress: true }
149 : {}),
147150 },
148151 };
149152
apps/server/src/ws.ts+29
......@@ -40,6 +40,8 @@ import {
4040 ProjectWriteFileError,
4141 RelayClientInstallFailedError,
4242 type RelayClientInstallProgressEvent,
43 type ServerSelfUpdateError,
44 type ServerSelfUpdateProgressEvent,
4345 type FilesystemBrowseFailure,
4446 FilesystemBrowseError,
4547 AssetWorkspaceContextNotFoundError,
......@@ -1364,6 +1366,33 @@ const makeWsRpcLayer = (
13641366 observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), {
13651367 "rpc.aggregate": "server",
13661368 }),
1369 [WS_METHODS.serverUpdateServerWithProgress]: (input) =>
1370 observeRpcStream(
1371 WS_METHODS.serverUpdateServerWithProgress,
1372 Stream.callback<ServerSelfUpdateProgressEvent, ServerSelfUpdateError>((queue) =>
1373 serverSelfUpdate
1374 .update(input, (stage) =>
1375 Queue.offer(queue, {
1376 type: "progress",
1377 stage,
1378 }).pipe(Effect.asVoid),
1379 )
1380 .pipe(
1381 Effect.flatMap((result) =>
1382 Queue.offer(queue, {
1383 type: "complete",
1384 result,
1385 }),
1386 ),
1387 Effect.catchTags({
1388 ServerSelfUpdateError: (error) => Queue.fail(queue, error),
1389 }),
1390 Effect.andThen(Queue.end(queue)),
1391 Effect.forkScoped,
1392 ),
1393 ),
1394 { "rpc.aggregate": "server" },
1395 ),
13671396 [WS_METHODS.serverUpsertKeybinding]: (rule) =>
13681397 observeRpcEffect(
13691398 WS_METHODS.serverUpsertKeybinding,
apps/web/src/components/ChatView.tsx+48-25
......@@ -296,7 +296,7 @@ import {
296296 AlertDialogTitle,
297297} from "./ui/alert-dialog";
298298import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
299import { ServerUpdateAction } from "./ServerUpdateAction";
299import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction";
300300import {
301301 buildVersionMismatchDismissalKey,
302302 dismissVersionMismatch,
......@@ -1876,12 +1876,16 @@ function ChatViewContent(props: ChatViewProps) {
18761876 hasMultipleRegisteredEnvironments && activeThread
18771877 ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server`
18781878 : "server";
1879 const versionMismatchEnvironmentId =
1880 versionMismatch && activeThread ? activeThread.environmentId : null;
1879 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
18811880 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
1881 const serverUpdateState = useAtomValue(
1882 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
1883 );
18821884 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
18831885 const items: ComposerBannerStackItem[] = [];
1884 if (activeEnvironmentUnavailableState) {
1886 const resumingServerUpdate =
1887 serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";
1888 if (activeEnvironmentUnavailableState && !resumingServerUpdate) {
18851889 const connection = activeEnvironmentUnavailableState.connection;
18861890 const isReconnecting =
18871891 connection.phase === "connecting" || connection.phase === "reconnecting";
......@@ -1918,39 +1922,57 @@ function ChatViewContent(props: ChatViewProps) {
19181922 });
19191923 }
19201924 if (
1921 showVersionMismatchBanner &&
1922 versionMismatch &&
1923 versionMismatchDismissKey &&
1924 versionMismatchEnvironmentId
1925 serverUpdateEnvironmentId &&
1926 (serverUpdateState.status !== "idle" ||
1927 (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))
19251928 ) {
1929 const updateInProgress = serverUpdateState.status === "running";
1930 const updateFailed = serverUpdateState.status === "failed";
19261931 items.push({
1927 id: `version-mismatch:${versionMismatchDismissKey}`,
1928 variant: "warning",
1932 id: `server-version:${serverUpdateEnvironmentId}`,
1933 variant: updateFailed ? "error" : "warning",
19291934 icon: <TriangleAlertIcon />,
1930 title: "Client and server versions differ",
1931 description: (
1932 <>
1933 Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "}
1934 {versionMismatch.serverVersion}.{" "}
1935 {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)}
1936 </>
1937 ),
1935 title:
1936 updateInProgress || updateFailed
1937 ? `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
1938 : "Client and server versions differ",
1939 description:
1940 updateInProgress || updateFailed ? (
1941 <ServerUpdateProgress
1942 fromVersion={serverUpdateState.fromVersion}
1943 serverLabel={versionMismatchServerLabel}
1944 state={serverUpdateState}
1945 />
1946 ) : versionMismatch ? (
1947 <>
1948 Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "}
1949 {versionMismatch.serverVersion}.{" "}
1950 {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)}
1951 </>
1952 ) : null,
19381953 // The desktop-managed guidance is already the description; the action
19391954 // slot would only repeat it.
19401955 actions:
1956 updateInProgress ||
1957 !versionMismatch ||
19411958 versionMismatchSelfUpdate === "desktop-managed" ? undefined : (
19421959 <ServerUpdateAction
1943 environmentId={versionMismatchEnvironmentId}
1960 environmentId={serverUpdateEnvironmentId}
19441961 serverLabel={versionMismatchServerLabel}
19451962 selfUpdate={versionMismatchSelfUpdate}
19461963 targetVersion={versionMismatch.clientVersion}
1964 {...(updateFailed ? { label: "Retry update" } : {})}
19471965 />
19481966 ),
1949 dismissLabel: "Dismiss version mismatch warning",
1950 onDismiss: () => {
1951 dismissVersionMismatch(versionMismatchDismissKey);
1952 setDismissedVersionMismatchKey(versionMismatchDismissKey);
1953 },
1967 ...(updateInProgress || updateFailed || !versionMismatchDismissKey
1968 ? {}
1969 : {
1970 dismissLabel: "Dismiss version mismatch warning",
1971 onDismiss: () => {
1972 dismissVersionMismatch(versionMismatchDismissKey);
1973 setDismissedVersionMismatchKey(versionMismatchDismissKey);
1974 },
1975 }),
19541976 });
19551977 }
19561978 return items;
......@@ -1960,9 +1982,10 @@ function ChatViewContent(props: ChatViewProps) {
19601982 navigate,
19611983 setDismissedVersionMismatchKey,
19621984 showVersionMismatchBanner,
1985 serverUpdateState,
19631986 versionMismatch,
19641987 versionMismatchDismissKey,
1965 versionMismatchEnvironmentId,
1988 serverUpdateEnvironmentId,
19661989 versionMismatchSelfUpdate,
19671990 versionMismatchServerLabel,
19681991 ]);
apps/web/src/components/ServerUpdateAction.test.tsx+81-134
......@@ -1,71 +1,15 @@
1import type { Dispatch, ReactElement, SetStateAction } from "react";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1import type { ReactElement } from "react";
2import { renderToStaticMarkup } from "react-dom/server";
3import type { EnvironmentId } from "@t3tools/contracts";
34import * as Cause from "effect/Cause";
45import { AsyncResult } from "effect/unstable/reactivity";
5import type { EnvironmentId } from "@t3tools/contracts";
6import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
67
78const testState = vi.hoisted(() => ({
89 updateServer: vi.fn(),
910 toast: vi.fn(),
1011}));
1112
12const hooks = vi.hoisted(() => {
13 let cursor = 0;
14 let slots: unknown[] = [];
15 const nextIndex = () => cursor++;
16
17 return {
18 beginRender() {
19 cursor = 0;
20 },
21 reset() {
22 cursor = 0;
23 slots = [];
24 },
25 useEffect() {
26 nextIndex();
27 },
28 useMemoCache(size: number): unknown[] {
29 const index = nextIndex();
30 if (!slots[index]) {
31 slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel"));
32 }
33 return slots[index] as unknown[];
34 },
35 useRef<T>(initialValue: T): { current: T } {
36 const index = nextIndex();
37 if (!slots[index]) {
38 slots[index] = { current: initialValue };
39 }
40 return slots[index] as { current: T };
41 },
42 useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
43 const index = nextIndex();
44 if (index >= slots.length) {
45 slots[index] =
46 typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
47 }
48 const setValue: Dispatch<SetStateAction<T>> = (nextValue) => {
49 const previous = slots[index] as T;
50 slots[index] =
51 typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
52 };
53 return [slots[index] as T, setValue];
54 },
55 };
56});
57
58vi.mock("react", async (importOriginal) => {
59 const actual = await importOriginal<typeof import("react")>();
60 return {
61 ...actual,
62 useEffect: hooks.useEffect,
63 useRef: hooks.useRef,
64 useState: hooks.useState,
65 };
66});
67
68vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));
6913vi.mock("~/hooks/useCopyToClipboard", () => ({
7014 useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }),
7115}));
......@@ -79,120 +23,123 @@ vi.mock("./ui/toast", () => ({
7923 toastManager: { add: testState.toast },
8024}));
8125
82import { ServerUpdateAction } from "./ServerUpdateAction";
26import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction";
8327
8428type ActionElement = ReactElement<{
85 readonly disabled?: boolean;
8629 readonly onClick?: () => void;
8730}>;
8831
8932function renderAction(): ActionElement {
90 hooks.beginRender();
9133 return ServerUpdateAction({
9234 environmentId: "env-test" as EnvironmentId,
9335 serverLabel: "Test server",
9436 selfUpdate: "boot-service",
95 targetVersion: "0.0.29",
37 targetVersion: "0.0.31",
9638 }) as ActionElement;
9739}
9840
99function deferred<T>() {
100 let resolve!: (value: T) => void;
101 const promise = new Promise<T>((complete) => {
102 resolve = complete;
103 });
104 return { promise, resolve };
105}
106
10741async function flushPromises(): Promise<void> {
10842 await Promise.resolve();
10943 await Promise.resolve();
110 await Promise.resolve();
111 await Promise.resolve();
112 await Promise.resolve();
11344}
11445
11546describe("ServerUpdateAction", () => {
11647 beforeEach(() => {
117 vi.useFakeTimers();
118 hooks.reset();
11948 testState.updateServer.mockReset();
12049 testState.toast.mockReset();
12150 });
12251
123 afterEach(() => {
124 vi.useRealTimers();
125 });
126
127 it("starts a fresh reconnect timeout after a long install succeeds", async () => {
128 const update =
129 deferred<
130 ReturnType<typeof AsyncResult.success<{ targetVersion: string; method: "boot-service" }>>
131 >();
132 testState.updateServer.mockReturnValue(update.promise);
52 it("reports success only after the shared update flow reconnects", async () => {
53 testState.updateServer.mockResolvedValue(
54 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
55 );
13356
13457 renderAction().props.onClick?.();
135 expect(renderAction().props.disabled).toBe(true);
136
137 await vi.advanceTimersByTimeAsync(11 * 60_000);
138 update.resolve(
139 AsyncResult.success({
140 targetVersion: "0.0.29",
141 method: "boot-service",
142 }),
143 );
14458 await flushPromises();
14559
146 // The click-based deadline would have fired by now. Success gets a fresh
147 // twelve-minute reconnect window, so the action remains disabled.
148 await vi.advanceTimersByTimeAsync(2 * 60_000);
149 expect(renderAction().props.disabled).toBe(true);
150 expect(testState.toast).not.toHaveBeenCalledWith(
151 expect.objectContaining({ title: "Server update timed out" }),
152 );
153
154 await vi.advanceTimersByTimeAsync(10 * 60_000);
155 expect(renderAction().props.disabled).not.toBe(true);
156 expect(testState.toast).toHaveBeenCalledWith(
157 expect.objectContaining({ title: "Server update timed out" }),
158 );
60 expect(testState.updateServer).toHaveBeenCalledWith({
61 environmentId: "env-test",
62 input: { targetVersion: "0.0.31" },
63 });
64 expect(testState.toast).toHaveBeenCalledWith({
65 type: "success",
66 title: "Test server updated",
67 description: "Reconnected on t3@0.0.31.",
68 });
15969 });
16070
161 it("does not let an expired request clear a newer retry", async () => {
162 const first = deferred<ReturnType<typeof AsyncResult.failure>>();
163 const retry =
164 deferred<
165 ReturnType<typeof AsyncResult.success<{ targetVersion: string; method: "boot-service" }>>
166 >();
167 testState.updateServer.mockReturnValueOnce(first.promise).mockReturnValueOnce(retry.promise);
168
169 renderAction().props.onClick?.();
170 await vi.advanceTimersByTimeAsync(12 * 60_000);
171 expect(renderAction().props.disabled).not.toBe(true);
172
173 renderAction().props.onClick?.();
174 expect(renderAction().props.disabled).toBe(true);
175
176 first.resolve(AsyncResult.failure(Cause.fail(new Error("first request failed late"))));
177 await flushPromises();
71 it("reports one result when the update action is double-clicked", async () => {
72 let finishUpdate: (() => void) | undefined;
73 testState.updateServer.mockImplementation(
74 () =>
75 new Promise((resolve) => {
76 finishUpdate = () =>
77 resolve(
78 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
79 );
80 }),
81 );
17882
179 expect(renderAction().props.disabled).toBe(true);
180 expect(testState.updateServer).toHaveBeenCalledTimes(2);
83 const action = renderAction();
84 action.props.onClick?.();
85 action.props.onClick?.();
18186
182 retry.resolve(AsyncResult.success({ targetVersion: "0.0.29", method: "boot-service" }));
87 expect(testState.updateServer).toHaveBeenCalledTimes(1);
88 finishUpdate?.();
18389 await flushPromises();
184 expect(renderAction().props.disabled).toBe(true);
90 expect(testState.toast).toHaveBeenCalledTimes(1);
18591 });
18692
187 it("quietly releases the action when a restart RPC is interrupted", async () => {
93 it("quietly releases the action when the operation is interrupted", async () => {
18894 testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt()));
18995
19096 renderAction().props.onClick?.();
19197 await flushPromises();
19298
193 expect(renderAction().props.disabled).not.toBe(true);
194 expect(testState.toast).not.toHaveBeenCalledWith(
195 expect.objectContaining({ title: "Server update failed" }),
99 expect(testState.toast).not.toHaveBeenCalled();
100 });
101});
102
103describe("ServerUpdateProgress", () => {
104 it("renders the chosen horizontal step rail without an animated spinner", () => {
105 const markup = renderToStaticMarkup(
106 <ServerUpdateProgress
107 fromVersion="0.0.30"
108 serverLabel="bb-1"
109 state={{
110 status: "running",
111 stage: "resuming",
112 fromVersion: "0.0.30",
113 targetVersion: "0.0.31",
114 }}
115 />,
116 );
117
118 expect(markup).toContain("0.0.30");
119 expect(markup).toContain("0.0.31");
120 expect(markup).toContain("Download");
121 expect(markup).toContain("Install");
122 expect(markup).toContain("Resume");
123 expect(markup).toContain("Waiting for bb-1 to accept commands.");
124 expect(markup).not.toContain("animate-spin");
125 });
126
127 it("keeps the failed stage visible with its retryable error", () => {
128 const markup = renderToStaticMarkup(
129 <ServerUpdateProgress
130 fromVersion="0.0.30"
131 serverLabel="bb-1"
132 state={{
133 status: "failed",
134 stage: "installing",
135 fromVersion: "0.0.30",
136 targetVersion: "0.0.31",
137 message: "The package could not be verified.",
138 }}
139 />,
196140 );
141
142 expect(markup).toContain('role="alert"');
143 expect(markup).toContain("The package could not be verified.");
197144 });
198145});
apps/web/src/components/ServerUpdateAction.tsx+132-117
......@@ -1,55 +1,147 @@
1import { useEffect, useRef, useState } from "react";
21import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
33import {
44 isAtomCommandInterrupted,
55 squashAtomCommandFailure,
66} from "@t3tools/client-runtime/state/runtime";
7import { CheckIcon } from "lucide-react";
78
89import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
10import { cn } from "~/lib/utils";
911import { serverEnvironment } from "~/state/server";
1012import { useAtomCommand } from "~/state/use-atom-command";
1113import { manualServerUpdateCommand } from "~/versionSkew";
1214import { Button } from "./ui/button";
13import { Spinner } from "./ui/spinner";
1415import { toastManager } from "./ui/toast";
1516
16/**
17 * The npm install on the server side is capped at 10 minutes; expire the
18 * spinner a bit beyond that so a dead transport never strands a disabled
19 * button, while a legitimately slow install is never cut off.
20 */
21const UPDATE_PENDING_EXPIRY_MS = 12 * 60_000;
17const UPDATE_STEPS = [
18 { stage: "downloading", label: "Download" },
19 { stage: "installing", label: "Install" },
20 { stage: "resuming", label: "Resume" },
21] as const;
22const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();
2223
2324function updateFailureMessage(error: unknown): string {
2425 return error instanceof Error ? error.message : "Server update failed.";
2526}
2627
28function updateStatusCopy(
29 state: Exclude<ServerUpdateState, { status: "idle" }>,
30 serverLabel: string,
31): string {
32 if (state.status === "failed") {
33 return state.message;
34 }
35 switch (state.stage) {
36 case "downloading":
37 return "Downloading the matching server version.";
38 case "installing":
39 return `Installing and verifying t3@${state.targetVersion}.`;
40 case "resuming":
41 return `Waiting for ${serverLabel} to accept commands.`;
42 }
43}
44
45export function ServerUpdateProgress({
46 fromVersion,
47 serverLabel,
48 state,
49}: {
50 readonly fromVersion: string;
51 readonly serverLabel: string;
52 readonly state: Exclude<ServerUpdateState, { status: "idle" }>;
53}) {
54 const currentIndex = UPDATE_STEPS.findIndex(({ stage }) => stage === state.stage);
55
56 return (
57 <div className="mt-1 min-w-0 space-y-2.5">
58 <p className="text-xs text-muted-foreground">
59 {fromVersion} <span aria-hidden="true">→</span> {state.targetVersion}
60 </p>
61 <ol className="flex min-w-0 items-center" aria-label="Server update progress">
62 {UPDATE_STEPS.map((step, index) => {
63 const complete = index < currentIndex;
64 const current = index === currentIndex;
65 const failed = current && state.status === "failed";
66 return (
67 <li
68 key={step.stage}
69 className={cn(
70 "flex min-w-0 items-center text-xs font-medium",
71 index < UPDATE_STEPS.length - 1 ? "flex-1" : null,
72 complete
73 ? "text-success"
74 : failed
75 ? "text-destructive"
76 : current
77 ? "text-primary"
78 : "text-muted-foreground/60",
79 )}
80 aria-current={current && state.status === "running" ? "step" : undefined}
81 >
82 <span
83 className={cn(
84 "grid size-4 shrink-0 place-items-center rounded-full border",
85 complete
86 ? "border-success bg-success text-success-foreground"
87 : failed
88 ? "border-destructive bg-destructive text-destructive-foreground"
89 : current
90 ? "border-primary bg-primary text-primary-foreground"
91 : "border-muted-foreground/35",
92 )}
93 aria-hidden="true"
94 >
95 {complete ? <CheckIcon className="size-3" strokeWidth={3} /> : null}
96 </span>
97 <span className="ml-1.5 truncate">{step.label}</span>
98 {index < UPDATE_STEPS.length - 1 ? (
99 <span
100 className={cn(
101 "mx-2 h-px min-w-3 flex-1",
102 complete ? "bg-success/60" : "bg-border",
103 )}
104 aria-hidden="true"
105 />
106 ) : null}
107 </li>
108 );
109 })}
110 </ol>
111 <p
112 className={cn(
113 "text-xs",
114 state.status === "failed" ? "text-destructive" : "text-muted-foreground",
115 )}
116 role={state.status === "failed" ? "alert" : "status"}
117 >
118 {updateStatusCopy(state, serverLabel)}
119 </p>
120 </div>
121 );
122}
123
27124/**
28 * The call-to-action for a version-skewed server, matched to the update path
29 * it advertises: a one-click install-and-restart for servers that can update
30 * themselves, an update-the-desktop-app hint for desktop-managed backends
31 * (running `npx t3` there would start a second server, not update this one),
32 * and copying the manual relaunch command for everything else — so the skew
33 * warning always offers a way out.
125 * Offers the update path advertised by a version-skewed server. Self-updates
126 * delegate their full lifecycle to client-runtime so this component can
127 * unmount during reconnect without losing operation state.
34128 */
35129export function ServerUpdateAction({
36130 environmentId,
37131 serverLabel,
38132 selfUpdate,
39133 targetVersion,
134 label = "Update server",
40135}: {
41136 readonly environmentId: EnvironmentId;
42137 readonly serverLabel: string;
43138 readonly selfUpdate: ServerSelfUpdateCapability | null;
44139 readonly targetVersion: string;
140 readonly label?: string;
45141}) {
46142 const updateServer = useAtomCommand(serverEnvironment.updateServer, {
47143 reportFailure: false,
48144 });
49 const [pending, setPending] = useState(false);
50 const inFlightRef = useRef(false);
51 const attemptRef = useRef(0);
52 const expiryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
53145 const { copyToClipboard } = useCopyToClipboard<{ command: string }>({
54146 target: "update command",
55147 onCopy: ({ command }) => {
......@@ -68,107 +160,35 @@ export function ServerUpdateAction({
68160 },
69161 });
70162
71 useEffect(
72 () => () => {
73 if (expiryRef.current !== null) {
74 clearTimeout(expiryRef.current);
75 expiryRef.current = null;
76 }
77 attemptRef.current += 1;
78 inFlightRef.current = false;
79 },
80 [],
81 );
82
83 const handleUpdate = () => {
84 // Synchronous re-entry guard: setPending is async, so a rapid
85 // double-click would otherwise dispatch two updates.
86 if (inFlightRef.current) {
163 const handleUpdate = async () => {
164 if (pendingUpdateEnvironmentIds.has(environmentId)) {
87165 return;
88166 }
89 inFlightRef.current = true;
90 const attempt = attemptRef.current + 1;
91 attemptRef.current = attempt;
92 const ownsAttempt = () => attemptRef.current === attempt;
93 setPending(true);
94 const armExpiry = () => {
95 const expiry = setTimeout(() => {
96 if (!ownsAttempt()) return;
97 expiryRef.current = null;
98 attemptRef.current += 1;
99 inFlightRef.current = false;
100 setPending(false);
101 toastManager.add({
102 type: "error",
103 title: "Server update timed out",
104 description: "The update may still be running on the server — check again in a minute.",
105 });
106 }, UPDATE_PENDING_EXPIRY_MS);
107 expiryRef.current = expiry;
108 return expiry;
109 };
110 let expiry = armExpiry();
111 let restartAccepted = false;
112 const keepPendingForRestart = () => {
113 restartAccepted = true;
114 if (expiryRef.current === expiry) {
115 clearTimeout(expiry);
116 expiry = armExpiry();
117 }
118 };
119 void Promise.resolve()
120 .then(() =>
121 updateServer({
122 environmentId,
123 input: { targetVersion },
124 }),
125 )
126 .then((result) => {
127 if (!ownsAttempt()) return;
128 if (result._tag === "Failure") {
129 // An interrupt may be the expected boot-service disconnect, but it
130 // can also be client-side cancellation before restart was accepted.
131 // Release the action quietly; version sync will remove it when a
132 // successful replacement reconnects.
133 if (isAtomCommandInterrupted(result)) {
134 return;
135 }
136 toastManager.add({
137 type: "error",
138 title: "Server update failed",
139 description: updateFailureMessage(squashAtomCommandFailure(result)),
140 });
167 pendingUpdateEnvironmentIds.add(environmentId);
168 try {
169 const result = await updateServer({
170 environmentId,
171 input: { targetVersion },
172 });
173 if (result._tag === "Failure") {
174 if (isAtomCommandInterrupted(result)) {
141175 return;
142176 }
143 keepPendingForRestart();
144 // Installation can legitimately consume most of the request window.
145 // Give restart/reconnect a fresh full window after the server accepts
146 // the handoff instead of expiring based on the original click time.
147 toastManager.add({
148 type: "success",
149 title: `Updating ${serverLabel}`,
150 description: `t3@${result.value.targetVersion} is installed — the server is restarting and will reconnect shortly.`,
151 });
152 })
153 .catch((error: unknown) => {
154 if (!ownsAttempt()) return;
155177 toastManager.add({
156178 type: "error",
157179 title: "Server update failed",
158 description: updateFailureMessage(error),
180 description: updateFailureMessage(squashAtomCommandFailure(result)),
159181 });
160 })
161 .finally(() => {
162 // A successful RPC only acknowledges that restart is scheduled. Keep
163 // the action disabled until version sync unmounts it, or until the
164 // safety expiry reports that reconnection never arrived.
165 if (restartAccepted || !ownsAttempt() || expiryRef.current !== expiry) return;
166 expiryRef.current = null;
167 clearTimeout(expiry);
168 attemptRef.current += 1;
169 inFlightRef.current = false;
170 setPending(false);
182 return;
183 }
184 toastManager.add({
185 type: "success",
186 title: `${serverLabel} updated`,
187 description: `Reconnected on t3@${result.value.targetVersion}.`,
171188 });
189 } finally {
190 pendingUpdateEnvironmentIds.delete(environmentId);
191 }
172192 };
173193
174194 if (selfUpdate === "desktop-managed") {
......@@ -188,14 +208,9 @@ export function ServerUpdateAction({
188208 );
189209 }
190210
191 return pending ? (
192 <Button size="xs" disabled>
193 <Spinner className="size-3.5" />
194 Updating...
195 </Button>
196 ) : (
211 return (
197212 <Button size="xs" onClick={() => void handleUpdate()}>
198 Update server
213 {label}
199214 </Button>
200215 );
201216}
apps/web/src/components/settings/ConnectionsSettings.tsx+50-13
......@@ -7,6 +7,7 @@ import {
77 TerminalIcon,
88 TriangleAlertIcon,
99} from "lucide-react";
10import { useAtomValue } from "@effect/atom-react";
1011import { type ReactNode, memo, useCallback, useMemo, useState } from "react";
1112import {
1213 AuthAccessReadScope,
......@@ -131,8 +132,9 @@ import {
131132 usePrimaryEnvironment,
132133} from "~/state/environments";
133134import { useAtomCommand } from "../../state/use-atom-command";
135import { serverEnvironment } from "~/state/server";
134136import { ConnectionStatusDot } from "../ConnectionStatusDot";
135import { ServerUpdateAction } from "../ServerUpdateAction";
137import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction";
136138import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
137139import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
138140
......@@ -1389,6 +1391,9 @@ function SavedBackendListRow({
13891391 [copyTraceIdToClipboard],
13901392 );
13911393 const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
1394 const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
1395 const resumingServerUpdate =
1396 serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";
13921397 const sshTarget =
13931398 environment.entry.target._tag === "SshConnectionTarget" &&
13941399 Option.isSome(environment.entry.profile) &&
......@@ -1425,14 +1430,22 @@ function SavedBackendListRow({
14251430 {metadataBits.length > 0 ? (
14261431 <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>
14271432 ) : null}
1428 {versionMismatch ? (
1433 {serverUpdateState.status !== "idle" ? (
1434 <div className="max-w-md">
1435 <ServerUpdateProgress
1436 fromVersion={serverUpdateState.fromVersion}
1437 serverLabel={`${environment.label} server`}
1438 state={serverUpdateState}
1439 />
1440 </div>
1441 ) : versionMismatch ? (
14291442 <p className="flex items-center gap-1 text-warning text-xs">
14301443 <TriangleAlertIcon className="size-3.5 shrink-0" />
14311444 Version drift: client {versionMismatch.clientVersion}, server{" "}
14321445 {versionMismatch.serverVersion}.
14331446 </p>
14341447 ) : null}
1435 {environment.connection.error ? (
1448 {environment.connection.error && !resumingServerUpdate ? (
14361449 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">
14371450 <span className="truncate">{connectionStatusText(environment.connection)}</span>
14381451 {errorTraceId ? (
......@@ -1448,12 +1461,14 @@ function SavedBackendListRow({
14481461 ) : null}
14491462 </div>
14501463 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
1451 {versionMismatch ? (
1464 {versionMismatch &&
1465 (serverUpdateState.status === "idle" || serverUpdateState.status === "failed") ? (
14521466 <ServerUpdateAction
14531467 environmentId={environmentId}
14541468 serverLabel={`${environment.label} server`}
14551469 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
14561470 targetVersion={versionMismatch.clientVersion}
1471 label={serverUpdateState.status === "failed" ? "Retry update" : "Update server"}
14571472 />
14581473 ) : null}
14591474 {isWslEnvironment ? (
......@@ -1834,6 +1849,9 @@ export function ConnectionsSettings() {
18341849 >(null);
18351850 const primaryServerConfig = primaryEnvironment?.serverConfig ?? null;
18361851 const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig);
1852 const primaryServerUpdateState = useAtomValue(
1853 serverEnvironment.updateStateAtom(primaryEnvironmentId),
1854 );
18371855 const [isAdvertisedEndpointListExpanded, setIsAdvertisedEndpointListExpanded] = useState(false);
18381856 const defaultAdvertisedEndpointKey = useUiStateStore(
18391857 (state) => state.defaultAdvertisedEndpointKey,
......@@ -2980,24 +2998,43 @@ export function ConnectionsSettings() {
29802998 {canManageLocalBackend ? (
29812999 <>
29823000 <SettingsSection title="This environment">
2983 {primaryVersionMismatch ? (
3001 {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? (
29843002 <SettingsRow
2985 title="Version drift"
3003 title={
3004 primaryServerUpdateState.status === "failed"
3005 ? "Update failed"
3006 : primaryServerUpdateState.status === "running"
3007 ? "Updating server"
3008 : "Version drift"
3009 }
29863010 description={
2987 <span className="flex items-center gap-1 text-warning">
2988 <TriangleAlertIcon className="size-3.5 shrink-0" />
2989 Client {primaryVersionMismatch.clientVersion}, server{" "}
2990 {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects
2991 fail.
2992 </span>
3011 primaryServerUpdateState.status !== "idle" ? (
3012 <ServerUpdateProgress
3013 fromVersion={primaryServerUpdateState.fromVersion}
3014 serverLabel={primaryEnvironment?.label ?? "this server"}
3015 state={primaryServerUpdateState}
3016 />
3017 ) : primaryVersionMismatch ? (
3018 <span className="flex items-center gap-1 text-warning">
3019 <TriangleAlertIcon className="size-3.5 shrink-0" />
3020 Client {primaryVersionMismatch.clientVersion}, server{" "}
3021 {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects
3022 fail.
3023 </span>
3024 ) : null
29933025 }
29943026 control={
2995 primaryEnvironmentId !== null ? (
3027 primaryVersionMismatch &&
3028 primaryEnvironmentId !== null &&
3029 primaryServerUpdateState.status !== "running" ? (
29963030 <ServerUpdateAction
29973031 environmentId={primaryEnvironmentId}
29983032 serverLabel={primaryEnvironment?.label ?? "this server"}
29993033 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
30003034 targetVersion={primaryVersionMismatch.clientVersion}
3035 {...(primaryServerUpdateState.status === "failed"
3036 ? { label: "Retry update" }
3037 : {})}
30013038 />
30023039 ) : undefined
30033040 }
docs/architecture/server-updates.md+32-22
......@@ -13,8 +13,9 @@ The feature has three boundaries:
1313## Detection and Presentation
1414
1515`ExecutionEnvironmentDescriptor` includes the server version and an optional
16`capabilities.serverSelfUpdate` value. The client compares that version with `APP_VERSION` after
17loading server config.
16`capabilities.serverSelfUpdate` value. Progress-capable servers also advertise
17`capabilities.serverSelfUpdateProgress`. The client compares the server version with `APP_VERSION`
18after loading server config.
1819
1920The optional capability is intentionally backward compatible. An older server does not know about
2021the field, so a missing value means the client must offer a manual relaunch instead of sending an
......@@ -28,6 +29,10 @@ The shared `ServerUpdateAction` is rendered in both user-facing version-drift su
2829Both surfaces target the client's exact version. When the reconnected server reports that version,
2930the mismatch and action disappear.
3031
32The operation state lives in `packages/client-runtime`, keyed by environment. Both web surfaces read
33the same `downloading`, `installing`, or `resuming` state, so route changes do not own or cancel the
34operation.
35
3136## Capability Selection
3237
3338The server resolves its capability once at startup and publishes it in the environment descriptor.
......@@ -51,20 +56,25 @@ flowchart TD
5156 A[Client detects different versions] --> B{Advertised update path}
5257 B -->|desktop-managed| C[Update desktop app on server machine]
5358 B -->|missing| D[Copy exact manual relaunch command]
54 B -->|boot-service or respawn| E[server.updateServer]
55 E --> F[Install exact t3 version in pinned runtime]
56 F --> G[Run version preflight]
57 G -->|fails| H[Remove failed runtime and keep current server]
58 G -->|passes| I{Handoff method}
59 I -->|boot-service| J[Rewrite and restart T3 systemd unit]
60 I -->|respawn| K[Start delayed replacement and exit current process]
61 J --> L[Client reconnects]
62 K --> L
59 B -->|boot-service or respawn| E{Progress capability}
60 E -->|present| F[server.updateServerWithProgress]
61 E -->|missing| G[server.updateServer fallback]
62 F --> H[Download exact t3 version]
63 G --> H
64 H --> I[Install and run version preflight]
65 I -->|fails| J[Remove failed runtime and keep current server]
66 I -->|passes| K{Handoff method}
67 K -->|boot-service| L[Rewrite and restart T3 systemd unit]
68 K -->|respawn| M[Start delayed replacement and exit current process]
69 L --> N[Reconnect with fresh backoff]
70 M --> N
71 N --> O[Replacement publishes ready at target version]
6372```
6473
65`server.updateServer` requires the environment's `orchestration:operate` authorization scope. Its
74Both update RPCs require the environment's `orchestration:operate` authorization scope. Their
6675payload accepts only an exact npm version, including an exact prerelease version; dist-tags such as
67`latest` and `nightly` are rejected.
76`latest` and `nightly` are rejected. The unary `server.updateServer` method remains available so a
77new client can still repair skew with an older server.
6878
6979The update service permits one update at a time. It installs `t3@<version>` under
7080`<baseDir>/runtime/versions/<version>` and writes an install-complete sentinel only after npm exits
......@@ -89,20 +99,20 @@ authorization; it does not uninstall the host service.
8999## Process Handoff
90100
91101For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified
92runtime, reloads systemd, and restarts the unit. Reload and restart failures restore the previous
93unit before returning an error.
102runtime and reloads systemd. It acknowledges the handoff, then restarts the unit after the same
103short grace period used by foreground respawn. A rejected deferred restart restores the previous
104unit and is logged by the still-running process.
94105
95106For `respawn`, the server starts a detached, delayed replacement that replays the original CLI
96107arguments. It then acknowledges the request and schedules the current process to exit. The delays
97108give the acknowledgement time to cross direct or relayed connections before the socket closes.
98109
99There is no separate progress stream. The update request remains pending while npm installs and the
100client shows a disabled update action. A restart can interrupt the request normally; the connection
101runtime keeps the environment registered and reconnects through its usual retry path. After an
102acknowledged foreground handoff, the UI keeps the action pending until version sync removes it or a
103safety timeout releases it. If a boot-service restart closes the connection before acknowledgement,
104the UI releases the interrupted action without reporting a false update failure and lets reconnect
105and the next version check determine the result.
110Progress-capable servers emit `downloading` before installing the pinned runtime and `installing`
111before preflight and handoff. A terminal stream event acknowledges that restart is scheduled. The
112client then enters `resuming`, waits for the replacement lifecycle stream to publish `ready` with
113the target version, and only then completes the operation. It watches for the intentional
114disconnect's first backoff state and requests one fresh retry, which clears historical backoff debt
115without adding a separate reconnect loop.
106116
107117## Release Invariant
108118
docs/user/server-updates.md+9-4
......@@ -31,6 +31,11 @@ The update does not remove saved threads, settings, or project files.
3131The available action depends on how that server was started. T3 Code does not update connected
3232servers silently in the background.
3333
34After selecting **Update server**, the warning becomes a three-step progress rail:
35**Download**, **Install**, and **Resume**. The same progress appears in the conversation and in
36Connections, so navigating between them does not lose the update. A failed step remains visible
37with its error and an option to retry.
38
3439If the server uses the T3 Code background service, you can also update it directly on the host:
3540
3641```sh
......@@ -42,11 +47,11 @@ commands.
4247
4348## After the Update
4449
45Keep the web or desktop app open while the server restarts. When it reconnects with the matching
46version, the warning and update action disappear.
50Keep the web or desktop app open while the server restarts. The update completes only after the
51replacement server reports the requested version and is ready to accept commands. The warning and
52progress rail then disappear.
4753
48If the client reports a timeout, the server may still be finishing the update. Wait a minute, then
49reconnect or open **Settings** → **Connections** again. If the warning remains:
54If a step fails:
5055
51561. Retry the offered action once.
52572. Make sure you updated the machine named in the warning, not only the device you are using.
packages/client-runtime/src/connection/supervisor.test.ts+37
......@@ -513,6 +513,43 @@ describe("EnvironmentSupervisor", () => {
513513 }),
514514 );
515515
516 it.effect("explicit retry starts a fresh backoff sequence", () =>
517 Effect.gen(function* () {
518 const harness = yield* makeHarness({
519 prepare: () => Effect.fail(transient()),
520 });
521 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
522 initiallyDesired: true,
523 }).pipe(Effect.provide(harness.dependencies));
524
525 yield* awaitState(
526 supervisor.state,
527 (state) => state.phase === "backoff" && state.attempt === 1,
528 );
529 yield* TestClock.adjust("1 second");
530 yield* eventuallyState(
531 supervisor.state,
532 (state) => state.phase === "backoff" && state.attempt === 2,
533 );
534
535 yield* supervisor.retryNow;
536 yield* eventuallyState(
537 supervisor.state,
538 (state) => state.phase === "backoff" && state.attempt === 1,
539 );
540 expect(yield* Ref.get(harness.prepareCount)).toBe(3);
541
542 yield* TestClock.adjust("999 millis");
543 expect(yield* Ref.get(harness.prepareCount)).toBe(3);
544 yield* TestClock.adjust("1 milli");
545 yield* eventuallyState(
546 supervisor.state,
547 (state) => state.phase === "backoff" && state.attempt === 2,
548 );
549 expect(yield* Ref.get(harness.prepareCount)).toBe(4);
550 }).pipe(Effect.provide(TestClock.layer())),
551 );
552
516553 it.effect("keeps blocked failures idle until an external signal requests another attempt", () =>
517554 Effect.gen(function* () {
518555 const harness = yield* makeHarness({
packages/client-runtime/src/connection/supervisor.ts+8-1
......@@ -231,6 +231,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
231231 };
232232 const intent = yield* Ref.make(initialIntent);
233233 const signals = yield* Queue.unbounded<SupervisorSignal>();
234 const resetRetryState = yield* Ref.make(false);
234235 const state = yield* SubscriptionRef.make<SupervisorConnectionState>(
235236 !initialIntent.desired
236237 ? availableState(initialIntent, 0)
......@@ -643,6 +644,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
643644 };
644645
645646 for (;;) {
647 if (yield* Ref.getAndSet(resetRetryState, false)) {
648 failureCount = 0;
649 latestFailure = null;
650 pendingRetry = Option.none();
651 }
646652 const currentIntent = yield* Ref.get(intent);
647653 if (!currentIntent.desired) {
648654 resetRetryLadder();
......@@ -763,7 +769,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
763769 Effect.withSpan("EnvironmentSupervisor.disconnect"),
764770 );
765771
766 const retryNow = signal({ _tag: "RetryRequested" }).pipe(
772 const retryNow = Ref.set(resetRetryState, true).pipe(
773 Effect.andThen(signal({ _tag: "RetryRequested" })),
767774 Effect.withSpan("EnvironmentSupervisor.retryNow"),
768775 );
769776
packages/client-runtime/src/rpc/client.ts+2-1
......@@ -56,6 +56,7 @@ export type EnvironmentSubscriptionRpcTag =
5656
5757export type EnvironmentStreamCommandRpcTag =
5858 | typeof WS_METHODS.cloudInstallRelayClient
59 | typeof WS_METHODS.serverUpdateServerWithProgress
5960 | typeof WS_METHODS.gitRunStackedAction;
6061
6162export type EnvironmentStreamRpcTag =
......@@ -80,7 +81,7 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{
8081 }),
8182}) {}
8283
83const isRpcClientError = Schema.is(RpcClientError.RpcClientError);
84export const isRpcClientError = Schema.is(RpcClientError.RpcClientError);
8485
8586export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0];
8687
packages/client-runtime/src/state/runtime.test.ts+49
......@@ -13,6 +13,7 @@ import {
1313 environmentRpcKey,
1414 createAtomCommandScheduler,
1515 createRuntimeCommand,
16 scheduleAtomCommandEffect,
1617 executeAtomCommand,
1718 executeAtomQuery,
1819 isAtomCommandInterrupted,
......@@ -399,6 +400,54 @@ describe("runtime command runner", () => {
399400 registry.dispose();
400401 });
401402
403 it.effect("releases a shared scheduler lane before the outer command finishes", () =>
404 Effect.gen(function* () {
405 const handoffStarted = Latch.makeUnsafe();
406 const handoffComplete = Latch.makeUnsafe();
407 const resumeComplete = Latch.makeUnsafe();
408 const runtime = Atom.runtime(Layer.empty);
409 const scheduler = createAtomCommandScheduler();
410 const concurrency = { mode: "serial" as const, key: () => "shared" };
411 const updateCommand = createRuntimeCommand(runtime, {
412 label: "test.update",
413 execute: (_input: void, registry) =>
414 scheduleAtomCommandEffect(
415 registry,
416 scheduler,
417 concurrency,
418 undefined,
419 Effect.sync(() => handoffStarted.openUnsafe()).pipe(
420 Effect.andThen(handoffComplete.await),
421 ),
422 ).pipe(Effect.andThen(resumeComplete.await)),
423 });
424 const configCommand = createRuntimeCommand(runtime, {
425 label: "test.config",
426 scheduler,
427 concurrency,
428 execute: () => Effect.succeed("configured"),
429 });
430 const registry = AtomRegistry.make();
431
432 const update = updateCommand.run(registry, undefined);
433 yield* handoffStarted.await;
434 const config = configCommand.run(registry, undefined);
435 handoffComplete.openUnsafe();
436
437 expect(yield* Effect.promise(() => config)).toMatchObject({
438 _tag: "Success",
439 value: "configured",
440 waiting: false,
441 });
442 resumeComplete.openUnsafe();
443 expect(yield* Effect.promise(() => update)).toMatchObject({
444 _tag: "Success",
445 waiting: false,
446 });
447 registry.dispose();
448 }),
449 );
450
402451 it("deduplicates single-flight commands by key", async () => {
403452 const latch = Latch.makeUnsafe();
404453 let executions = 0;
packages/client-runtime/src/state/runtime.ts+22
......@@ -253,6 +253,28 @@ export function createAtomCommandScheduler(): AtomCommandScheduler {
253253 };
254254}
255255
256/** Runs one effect inside an existing command scheduler lane. */
257export function scheduleAtomCommandEffect<W, A, E, R>(
258 registry: AtomRegistry.AtomRegistry,
259 scheduler: AtomCommandScheduler,
260 concurrency: AtomCommandConcurrency<W>,
261 input: W,
262 effect: Effect.Effect<A, E, R>,
263): Effect.Effect<A, E, R> {
264 return Effect.gen(function* () {
265 const context = yield* Effect.context<R>();
266 const result = yield* Effect.promise((signal) =>
267 scheduler.schedule<W, A, E>(registry, concurrency, input, async () => {
268 const exit = await Effect.runPromiseExitWith(context)(effect, { signal });
269 return Exit.isSuccess(exit)
270 ? AsyncResult.success(exit.value)
271 : AsyncResult.failure(exit.cause);
272 }),
273 );
274 return result._tag === "Success" ? result.value : yield* Effect.failCause(result.cause);
275 });
276}
277
256278export async function runAtomCommand<W, A, E>(
257279 registry: AtomRegistry.AtomRegistry,
258280 command: AtomCommand<W, A, E>,
packages/client-runtime/src/state/server.test.ts+131-3
......@@ -6,11 +6,15 @@ import {
66 WS_METHODS,
77} from "@t3tools/contracts";
88import { describe, expect, it } from "@effect/vitest";
9import * as Cause from "effect/Cause";
910import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
1012import * as Option from "effect/Option";
1113import * as Queue from "effect/Queue";
1214import * as Stream from "effect/Stream";
1315import * as SubscriptionRef from "effect/SubscriptionRef";
16import { RpcClientError } from "effect/unstable/rpc";
17import * as Socket from "effect/unstable/socket/Socket";
1418
1519import {
1620 AVAILABLE_CONNECTION_STATE,
......@@ -24,8 +28,12 @@ import type { RpcSession } from "../rpc/session.ts";
2428import {
2529 applyServerConfigProjection,
2630 makeEnvironmentServerConfigState,
31 isLegacyUpdateHandoffLoss,
2732 projectServerWelcome,
2833 resolveServerConfigValue,
34 resolveServerUpdateProgressResult,
35 serverUpdateStateForProgressEvent,
36 serverUpdateStateForServerVersion,
2937} from "./server.ts";
3038
3139const CONFIG = {
......@@ -62,6 +70,109 @@ function session(client: WsRpcProtocolClient): RpcSession {
6270}
6371
6472describe("server state projection", () => {
73 it("only treats a legacy transport interruption as an unacknowledged handoff", () => {
74 expect(isLegacyUpdateHandoffLoss(Cause.interrupt(1))).toBe(true);
75 expect(
76 isLegacyUpdateHandoffLoss(
77 Cause.fail(
78 new RpcClientError.RpcClientError({
79 reason: new Socket.SocketCloseError({ code: 1006 }),
80 }),
81 ),
82 ),
83 ).toBe(true);
84 expect(
85 isLegacyUpdateHandoffLoss(
86 Cause.fail(
87 new RpcClientError.RpcClientError({
88 reason: new Socket.SocketOpenError({
89 kind: "Unknown",
90 cause: new Error("connection refused"),
91 }),
92 }),
93 ),
94 ),
95 ).toBe(false);
96 expect(
97 isLegacyUpdateHandoffLoss(
98 Cause.fail(
99 new RpcClientError.RpcClientError({
100 reason: new RpcClientError.RpcClientDefect({
101 message: "incompatible protocol",
102 cause: new Error("invalid response"),
103 }),
104 }),
105 ),
106 ),
107 ).toBe(false);
108 expect(isLegacyUpdateHandoffLoss(Cause.fail(new Error("Install failed.")))).toBe(false);
109 });
110
111 it.effect("resumes after the progress stream disconnects following completion", () => {
112 const result = {
113 targetVersion: "0.0.31",
114 method: "respawn" as const,
115 };
116 const disconnect = new RpcClientError.RpcClientError({
117 reason: new Socket.SocketCloseError({ code: 1006 }),
118 });
119
120 return Effect.gen(function* () {
121 const resumed = yield* resolveServerUpdateProgressResult(
122 result.targetVersion,
123 Option.some(result),
124 Exit.fail(disconnect),
125 );
126 expect(resumed).toEqual(result);
127 });
128 });
129
130 it("projects streamed update milestones into the shared operation state", () => {
131 expect(
132 serverUpdateStateForProgressEvent("0.0.30", "0.0.31", {
133 type: "progress",
134 stage: "installing",
135 }),
136 ).toEqual({
137 status: "running",
138 stage: "installing",
139 fromVersion: "0.0.30",
140 targetVersion: "0.0.31",
141 });
142 expect(
143 serverUpdateStateForProgressEvent("0.0.30", "0.0.31", {
144 type: "complete",
145 result: { targetVersion: "0.0.31", method: "respawn" },
146 }),
147 ).toEqual({
148 status: "running",
149 stage: "resuming",
150 fromVersion: "0.0.30",
151 targetVersion: "0.0.31",
152 });
153 });
154
155 it("keeps active update state and hides stale failures after a version change", () => {
156 const running = {
157 status: "running" as const,
158 stage: "resuming" as const,
159 fromVersion: "0.0.30",
160 targetVersion: "0.0.31",
161 };
162 const failed = {
163 status: "failed" as const,
164 stage: "installing" as const,
165 fromVersion: "0.0.30",
166 targetVersion: "0.0.31",
167 message: "Install failed.",
168 };
169
170 expect(serverUpdateStateForServerVersion(running, "0.0.31")).toBe(running);
171 expect(serverUpdateStateForServerVersion(failed, "0.0.30")).toBe(failed);
172 expect(serverUpdateStateForServerVersion(failed, null)).toBe(failed);
173 expect(serverUpdateStateForServerVersion(failed, "0.0.31")).toEqual({ status: "idle" });
174 });
175
65176 it("applies every config category to the projected snapshot", () => {
66177 const snapshot = applyServerConfigProjection(Option.none(), {
67178 version: 1,
......@@ -100,9 +211,16 @@ describe("server state projection", () => {
100211 });
101212
102213 it("prefers an active session config over cache until a live event arrives", () => {
103 const cached = { ...CONFIG, settings: { source: "cache" } } as unknown as ServerConfig;
104 const initial = { ...CONFIG, settings: { source: "session" } } as unknown as ServerConfig;
105 const live = { ...CONFIG, settings: { source: "live" } } as unknown as ServerConfig;
214 const config = (source: string, serverVersion: string) =>
215 ({
216 ...CONFIG,
217 environment: { serverVersion },
218 settings: { source },
219 }) as unknown as ServerConfig;
220 const cached = config("cache", "0.0.29");
221 const staleLive = config("stale-live", "0.0.29");
222 const initial = config("session", "0.0.30");
223 const live = config("live", "0.0.30");
106224
107225 expect(
108226 resolveServerConfigValue(
......@@ -114,6 +232,16 @@ describe("server state projection", () => {
114232 initial,
115233 ),
116234 ).toBe(initial);
235 expect(
236 resolveServerConfigValue(
237 {
238 config: staleLive,
239 latestEvent: snapshotEvent(staleLive),
240 source: "live",
241 },
242 initial,
243 ),
244 ).toBe(initial);
117245 expect(
118246 resolveServerConfigValue(
119247 {
packages/client-runtime/src/state/server.ts+326-9
......@@ -3,13 +3,19 @@ import {
33 type ServerConfig,
44 type ServerConfigStreamEvent,
55 type ServerLifecycleWelcomePayload,
6 type ServerSelfUpdateProgressEvent,
7 type ServerSelfUpdateResult,
68 WS_METHODS,
79} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
11import * as Duration from "effect/Duration";
812import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
914import * as Option from "effect/Option";
1015import * as Queue from "effect/Queue";
1116import * as Ref from "effect/Ref";
1217import * as Result from "effect/Result";
18import * as Schema from "effect/Schema";
1319import * as Stream from "effect/Stream";
1420import * as SubscriptionRef from "effect/SubscriptionRef";
1521import { AsyncResult, Atom } from "effect/unstable/reactivity";
......@@ -19,14 +25,148 @@ import {
1925 createEnvironmentRpcCommand,
2026 createEnvironmentRpcQueryAtomFamily,
2127 createEnvironmentRpcSubscriptionAtomFamily,
28 createRuntimeCommand,
29 scheduleAtomCommandEffect,
2230} from "./runtime.ts";
23import type { EnvironmentRegistry } from "../connection/registry.ts";
31import { EnvironmentRegistry } from "../connection/registry.ts";
2432import { EnvironmentSupervisor } from "../connection/supervisor.ts";
2533import { safeErrorLogAttributes } from "../errors/safeLog.ts";
2634import { EnvironmentCacheStore } from "../platform/persistence.ts";
27import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts";
35import {
36 isRpcClientError,
37 request,
38 runStream,
39 subscribe,
40 type EnvironmentRpcInput,
41} from "../rpc/client.ts";
2842import { followStreamInEnvironment } from "./runtime.ts";
2943
44export type ServerUpdateStage = "downloading" | "installing" | "resuming";
45
46export type ServerUpdateState =
47 | { readonly status: "idle" }
48 | {
49 readonly status: "running";
50 readonly stage: ServerUpdateStage;
51 readonly fromVersion: string;
52 readonly targetVersion: string;
53 }
54 | {
55 readonly status: "failed";
56 readonly stage: ServerUpdateStage;
57 readonly fromVersion: string;
58 readonly targetVersion: string;
59 readonly message: string;
60 };
61
62export interface ServerUpdateTarget {
63 readonly environmentId: EnvironmentId;
64 readonly input: EnvironmentRpcInput<typeof WS_METHODS.serverUpdateServer>;
65}
66
67const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" };
68const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make<ServerUpdateState>(IDLE_SERVER_UPDATE_STATE).pipe(
69 Atom.withLabel("environment-data:server:update-state:empty"),
70);
71const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) =>
72 Atom.make<ServerUpdateState>(IDLE_SERVER_UPDATE_STATE).pipe(
73 Atom.withLabel(`environment-data:server:update-state:${environmentId}`),
74 ),
75);
76
77export class ServerUpdateResumeTimeoutError extends Schema.TaggedErrorClass<ServerUpdateResumeTimeoutError>()(
78 "ServerUpdateResumeTimeoutError",
79 {
80 environmentId: Schema.String,
81 targetVersion: Schema.String,
82 },
83) {
84 override get message(): string {
85 return `The server did not resume on t3@${this.targetVersion}.`;
86 }
87}
88
89export class ServerUpdateProgressIncompleteError extends Schema.TaggedErrorClass<ServerUpdateProgressIncompleteError>()(
90 "ServerUpdateProgressIncompleteError",
91 {
92 targetVersion: Schema.String,
93 },
94) {
95 override get message(): string {
96 return `The t3@${this.targetVersion} update ended before the server accepted the restart.`;
97 }
98}
99
100export function serverUpdateStateForProgressEvent(
101 fromVersion: string,
102 targetVersion: string,
103 event: ServerSelfUpdateProgressEvent,
104): Extract<ServerUpdateState, { status: "running" }> {
105 return {
106 status: "running",
107 stage: event.type === "complete" ? "resuming" : event.stage,
108 fromVersion,
109 targetVersion,
110 };
111}
112
113export function serverUpdateStateForServerVersion(
114 state: ServerUpdateState,
115 serverVersion: string | null,
116): ServerUpdateState {
117 return state.status === "idle" ||
118 state.status === "running" ||
119 serverVersion === null ||
120 state.fromVersion === serverVersion
121 ? state
122 : IDLE_SERVER_UPDATE_STATE;
123}
124
125function serverUpdateFailureMessage(error: unknown): string {
126 return error instanceof Error ? error.message : "Server update failed.";
127}
128
129function isRpcSocketError(error: unknown): boolean {
130 if (!isRpcClientError(error)) {
131 return false;
132 }
133 switch (error.reason._tag) {
134 case "SocketReadError":
135 case "SocketWriteError":
136 case "SocketCloseError":
137 return true;
138 default:
139 return false;
140 }
141}
142
143export function isLegacyUpdateHandoffLoss(cause: Cause.Cause<unknown>): boolean {
144 if (Cause.hasInterruptsOnly(cause)) {
145 return true;
146 }
147 return (
148 cause.reasons.length > 0 &&
149 cause.reasons.every((reason) => Cause.isFailReason(reason) && isRpcSocketError(reason.error))
150 );
151}
152
153export function resolveServerUpdateProgressResult<E>(
154 targetVersion: string,
155 terminal: Option.Option<ServerSelfUpdateResult>,
156 streamExit: Exit.Exit<void, E>,
157): Effect.Effect<ServerSelfUpdateResult, E | ServerUpdateProgressIncompleteError> {
158 if (
159 Option.isSome(terminal) &&
160 (Exit.isSuccess(streamExit) || isLegacyUpdateHandoffLoss(streamExit.cause))
161 ) {
162 return Effect.succeed(terminal.value);
163 }
164 if (Exit.isFailure(streamExit)) {
165 return Effect.failCause(streamExit.cause);
166 }
167 return Effect.fail(new ServerUpdateProgressIncompleteError({ targetVersion }));
168}
169
30170export interface ServerConfigProjection {
31171 readonly config: ServerConfig;
32172 readonly latestEvent: ServerConfigStreamEvent;
......@@ -225,7 +365,13 @@ export function resolveServerConfigValue(
225365 projection: ServerConfigProjection | null,
226366 initialConfig: ServerConfig | null,
227367): ServerConfig | null {
228 if (projection?.source === "live") return projection.config;
368 if (
369 projection?.source === "live" &&
370 (initialConfig === null ||
371 projection.config.environment.serverVersion === initialConfig.environment.serverVersion)
372 ) {
373 return projection.config;
374 }
229375 return initialConfig ?? projection?.config ?? null;
230376}
231377
......@@ -238,6 +384,8 @@ export function createServerEnvironmentAtoms<R, E>(
238384 },
239385) {
240386 const configScheduler = createAtomCommandScheduler();
387 // Updates stay serial end-to-end, but only their handoff phase occupies the config lane.
388 const updateScheduler = createAtomCommandScheduler();
241389 const configConcurrency = {
242390 mode: "serial" as const,
243391 key: ({ environmentId }: { readonly environmentId: string }) => environmentId,
......@@ -271,6 +419,179 @@ export function createServerEnvironmentAtoms<R, E>(
271419 );
272420 }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`));
273421 });
422 const updateStateValueAtom = Atom.family((environmentId: EnvironmentId) =>
423 Atom.make((get) =>
424 serverUpdateStateForServerVersion(
425 get(serverUpdateStateAtom(environmentId)),
426 get(configValueAtom(environmentId))?.environment.serverVersion ?? null,
427 ),
428 ).pipe(Atom.withLabel(`environment-data:server:update-state-value:${environmentId}`)),
429 );
430 const updateStateAtom = (environmentId: EnvironmentId | null) =>
431 environmentId === null ? EMPTY_SERVER_UPDATE_STATE_ATOM : updateStateValueAtom(environmentId);
432 const updateServer = createRuntimeCommand<
433 EnvironmentRegistry | EnvironmentCacheStore | R,
434 E,
435 ServerUpdateTarget,
436 ServerSelfUpdateResult,
437 unknown
438 >(runtime, {
439 label: "environment-data:server:update-server",
440 scheduler: updateScheduler,
441 concurrency: configConcurrency,
442 execute: (target, atomRegistry) => {
443 const stateAtom = serverUpdateStateAtom(target.environmentId);
444 const targetVersion = target.input.targetVersion;
445 let fromVersion =
446 atomRegistry.get(configValueAtom(target.environmentId))?.environment.serverVersion ??
447 targetVersion;
448 let currentStage: ServerUpdateStage = "downloading";
449 atomRegistry.set(stateAtom, {
450 status: "running",
451 stage: currentStage,
452 fromVersion,
453 targetVersion,
454 });
455
456 return Effect.gen(function* () {
457 const environmentRegistry = yield* EnvironmentRegistry;
458 const result = yield* scheduleAtomCommandEffect(
459 atomRegistry,
460 configScheduler,
461 configConcurrency,
462 target,
463 Effect.gen(function* () {
464 const currentConfig = atomRegistry.get(configValueAtom(target.environmentId));
465 fromVersion = currentConfig?.environment.serverVersion ?? targetVersion;
466 atomRegistry.set(stateAtom, {
467 status: "running",
468 stage: currentStage,
469 fromVersion,
470 targetVersion,
471 });
472
473 const supportsProgress =
474 currentConfig?.environment.capabilities.serverSelfUpdateProgress === true;
475 const updateResult: ServerSelfUpdateResult = supportsProgress
476 ? yield* Effect.gen(function* () {
477 const terminal = yield* Ref.make<Option.Option<ServerSelfUpdateResult>>(
478 Option.none(),
479 );
480 const streamExit = yield* environmentRegistry
481 .runStream(
482 target.environmentId,
483 runStream(WS_METHODS.serverUpdateServerWithProgress, target.input),
484 )
485 .pipe(
486 Stream.runForEach((event) =>
487 Effect.sync(() => {
488 currentStage = event.type === "complete" ? "resuming" : event.stage;
489 atomRegistry.set(
490 stateAtom,
491 serverUpdateStateForProgressEvent(fromVersion, targetVersion, event),
492 );
493 }).pipe(
494 Effect.andThen(
495 event.type === "complete"
496 ? Ref.set(terminal, Option.some(event.result))
497 : Effect.void,
498 ),
499 ),
500 ),
501 Effect.exit,
502 );
503 return yield* resolveServerUpdateProgressResult(
504 targetVersion,
505 yield* Ref.get(terminal),
506 streamExit,
507 );
508 })
509 : yield* Effect.gen(function* () {
510 const selfUpdateMethod = currentConfig?.environment.capabilities.serverSelfUpdate;
511 const exit = yield* environmentRegistry
512 .run(target.environmentId, request(WS_METHODS.serverUpdateServer, target.input))
513 .pipe(Effect.exit);
514 if (Exit.isSuccess(exit)) {
515 return exit.value;
516 }
517 if (
518 (selfUpdateMethod === "boot-service" || selfUpdateMethod === "respawn") &&
519 isLegacyUpdateHandoffLoss(exit.cause)
520 ) {
521 // Older servers can tear down the transport before their
522 // unary acknowledgement arrives. Treat only that transport
523 // loss as a handoff, then prove it by waiting for target ready.
524 return { targetVersion, method: selfUpdateMethod };
525 }
526 return yield* Effect.failCause(exit.cause);
527 });
528
529 currentStage = "resuming";
530 atomRegistry.set(stateAtom, {
531 status: "running",
532 stage: currentStage,
533 fromVersion,
534 targetVersion,
535 });
536 return updateResult;
537 }),
538 );
539
540 // The update restart is intentional. As soon as the supervisor sees
541 // that first failed connection, discard any prior backoff debt and
542 // retry immediately instead of carrying an old 16-second delay.
543 yield* environmentRegistry.stateChanges(target.environmentId).pipe(
544 Stream.filter((state) => state.phase === "backoff"),
545 Stream.take(1),
546 Stream.runDrain,
547 Effect.andThen(environmentRegistry.retryNow(target.environmentId)),
548 Effect.timeoutOption(Duration.seconds(30)),
549 Effect.ignore,
550 Effect.forkChild,
551 );
552
553 const resumed = yield* environmentRegistry
554 .followStream(target.environmentId, subscribe(WS_METHODS.subscribeServerLifecycle, {}))
555 .pipe(
556 Stream.filter(
557 (event) =>
558 event.type === "ready" && event.payload.environment.serverVersion === targetVersion,
559 ),
560 Stream.runHead,
561 Effect.timeoutOption(Duration.seconds(120)),
562 Effect.map(Option.flatten),
563 );
564 if (Option.isNone(resumed)) {
565 return yield* new ServerUpdateResumeTimeoutError({
566 environmentId: target.environmentId,
567 targetVersion,
568 });
569 }
570
571 atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE);
572 return result;
573 }).pipe(
574 Effect.onExit((exit) =>
575 Effect.sync(() => {
576 if (Exit.isSuccess(exit)) {
577 return;
578 }
579 if (Cause.hasInterruptsOnly(exit.cause)) {
580 atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE);
581 return;
582 }
583 atomRegistry.set(stateAtom, {
584 status: "failed",
585 stage: currentStage,
586 fromVersion,
587 targetVersion,
588 message: serverUpdateFailureMessage(Cause.squash(exit.cause)),
589 });
590 }),
591 ),
592 );
593 },
594 });
274595 const settingsValueAtom = Atom.family((environmentId: EnvironmentId) =>
275596 Atom.make((get) => get(configValueAtom(environmentId))?.settings ?? null).pipe(
276597 Atom.withLabel(`environment-data:server:settings:${environmentId}`),
......@@ -284,6 +605,7 @@ export function createServerEnvironmentAtoms<R, E>(
284605
285606 return {
286607 configValueAtom,
608 updateStateAtom,
287609 settingsValueAtom,
288610 providersValueAtom,
289611 traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, {
......@@ -331,12 +653,7 @@ export function createServerEnvironmentAtoms<R, E>(
331653 scheduler: configScheduler,
332654 concurrency: configConcurrency,
333655 }),
334 updateServer: createEnvironmentRpcCommand(runtime, {
335 label: "environment-data:server:update-server",
336 tag: WS_METHODS.serverUpdateServer,
337 scheduler: configScheduler,
338 concurrency: configConcurrency,
339 }),
656 updateServer,
340657 upsertKeybinding: createEnvironmentRpcCommand(runtime, {
341658 label: "environment-data:server:upsert-keybinding",
342659 tag: WS_METHODS.serverUpsertKeybinding,
packages/contracts/src/environment.ts+3
......@@ -54,6 +54,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
5454 servers that must be relaunched manually (dev checkouts, Windows
5555 foreground runs, pre-update servers). */
5656 serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability),
57 /** Server can stream self-update progress before acknowledging the
58 restart. Clients fall back to server.updateServer when absent. */
59 serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean),
5760});
5861export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
5962
packages/contracts/src/rpc.ts+13
......@@ -127,6 +127,7 @@ import {
127127 ServerProviderUpdatedPayload,
128128 ServerSelfUpdateError,
129129 ServerSelfUpdateInput,
130 ServerSelfUpdateProgressEvent,
130131 ServerSelfUpdateResult,
131132 ServerTraceDiagnosticsResult,
132133 ServerProcessDiagnosticsResult,
......@@ -218,6 +219,7 @@ export const WS_METHODS = {
218219 serverRefreshProviders: "server.refreshProviders",
219220 serverUpdateProvider: "server.updateProvider",
220221 serverUpdateServer: "server.updateServer",
222 serverUpdateServerWithProgress: "server.updateServerWithProgress",
221223 serverUpsertKeybinding: "server.upsertKeybinding",
222224 serverRemoveKeybinding: "server.removeKeybinding",
223225 serverGetSettings: "server.getSettings",
......@@ -305,6 +307,16 @@ export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, {
305307 error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]),
306308});
307309
310export const WsServerUpdateServerWithProgressRpc = Rpc.make(
311 WS_METHODS.serverUpdateServerWithProgress,
312 {
313 payload: ServerSelfUpdateInput,
314 success: ServerSelfUpdateProgressEvent,
315 error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]),
316 stream: true,
317 },
318);
319
308320export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
309321 payload: Schema.Struct({}),
310322 success: ServerSettings,
......@@ -759,6 +771,7 @@ export const WsRpcGroup = RpcGroup.make(
759771 WsServerRefreshProvidersRpc,
760772 WsServerUpdateProviderRpc,
761773 WsServerUpdateServerRpc,
774 WsServerUpdateServerWithProgressRpc,
762775 WsServerUpsertKeybindingRpc,
763776 WsServerRemoveKeybindingRpc,
764777 WsServerGetSettingsRpc,
packages/contracts/src/server.ts+15
......@@ -592,6 +592,21 @@ export const ServerSelfUpdateResult = Schema.Struct({
592592});
593593export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type;
594594
595export const ServerSelfUpdateProgressStage = Schema.Literals(["downloading", "installing"]);
596export type ServerSelfUpdateProgressStage = typeof ServerSelfUpdateProgressStage.Type;
597
598export const ServerSelfUpdateProgressEvent = Schema.Union([
599 Schema.Struct({
600 type: Schema.Literal("progress"),
601 stage: ServerSelfUpdateProgressStage,
602 }),
603 Schema.Struct({
604 type: Schema.Literal("complete"),
605 result: ServerSelfUpdateResult,
606 }),
607]);
608export type ServerSelfUpdateProgressEvent = typeof ServerSelfUpdateProgressEvent.Type;
609
595610export class ServerSelfUpdateError extends Schema.TaggedErrorClass<ServerSelfUpdateError>()(
596611 "ServerSelfUpdateError",
597612 {