| author | |
| committer | |
| log | 197d3487116372ca2f16661f57d4e869fc3cb6c0 |
| tree | eeb4130265705a341ac320f2b416df7bb2c42118 |
| parent | 5c9358acaa34f8619513bba1e6821f0fdec27f74 |
| signature | Signed by PGP key B5690EEEBB952194 |
21 files changed, 1072 insertions(+), 364 deletions(-)
apps/server/src/auth/RpcAuthorization.ts+1| ... | ... | @@ -32,6 +32,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 32 | 32 | [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, |
| 33 | 33 | [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, |
| 34 | 34 | [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, |
| 35 | [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope, | |
| 35 | 36 | [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope, |
| 36 | 37 | [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, |
| 37 | 38 | [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, |
apps/server/src/cloud/selfUpdate.test.ts+40-5| ... | ... | @@ -26,6 +26,33 @@ import * as SelfUpdate from "./selfUpdate.ts"; |
| 26 | 26 | |
| 27 | 27 | const NODE_PATH = "/usr/local/bin/node"; |
| 28 | 28 | |
| 29 | const 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 | ||
| 46 | const 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 | ||
| 29 | 56 | interface RecordedCommand { |
| 30 | 57 | readonly command: string; |
| 31 | 58 | readonly args: ReadonlyArray<string>; |
| ... | ... | @@ -457,8 +484,12 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { |
| 457 | 484 | it.effect("installs, preflights, and respawns a foreground server", () => |
| 458 | 485 | Effect.gen(function* () { |
| 459 | 486 | 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 | ); | |
| 461 | 491 | assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); |
| 492 | assert.deepEqual(progress, ["downloading", "installing"]); | |
| 462 | 493 | assert.lengthOf(context.spawns, 1); |
| 463 | 494 | |
| 464 | 495 | const concurrentError = yield* context.service |
| ... | ... | @@ -506,10 +537,12 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { |
| 506 | 537 | assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`); |
| 507 | 538 | assert.deepEqual( |
| 508 | 539 | context.commands.map((entry) => entry.command), |
| 509 | ["npm", NODE_PATH, "systemctl", "systemctl"], | |
| 540 | ["npm", NODE_PATH, "systemctl"], | |
| 510 | 541 | ); |
| 511 | 542 | assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]); |
| 512 | 543 | |
| 544 | // Restart waits until after the update acknowledgement can flush. | |
| 545 | yield* TestClock.adjust(Duration.seconds(10)); | |
| 513 | 546 | assert.deepEqual(context.commands[3], { |
| 514 | 547 | command: "systemctl", |
| 515 | 548 | args: ["--user", "restart", "t3code.service"], |
| ... | ... | @@ -542,9 +575,11 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { |
| 542 | 575 | ); |
| 543 | 576 | const previousUnit = yield* context.fs.readFileString(unitPath); |
| 544 | 577 | |
| 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"); | |
| 548 | 583 | assert.deepEqual( |
| 549 | 584 | context.commands.slice(-2).map((entry) => entry.args), |
| 550 | 585 | [ |
apps/server/src/cloud/selfUpdate.ts+41-30| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ServerSelfUpdateError, |
| 7 | 7 | type ServerSelfUpdateCapability, |
| 8 | 8 | type ServerSelfUpdateInput, |
| 9 | type ServerSelfUpdateProgressStage, | |
| 9 | 10 | type ServerSelfUpdateResult, |
| 10 | 11 | } from "@t3tools/contracts"; |
| 11 | 12 | import { |
| ... | ... | @@ -147,6 +148,7 @@ export class ServerSelfUpdate extends Context.Service< |
| 147 | 148 | { |
| 148 | 149 | readonly update: ( |
| 149 | 150 | input: ServerSelfUpdateInput, |
| 151 | reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void, never, never>, | |
| 150 | 152 | ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>; |
| 151 | 153 | } |
| 152 | 154 | >()("t3/cloud/selfUpdate/ServerSelfUpdate") {} |
| ... | ... | @@ -227,7 +229,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option |
| 227 | 229 | |
| 228 | 230 | const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( |
| 229 | 231 | "cloud.server_self_update.update", |
| 230 | )(function* (input) { | |
| 232 | )(function* (input, reportProgress = () => Effect.void) { | |
| 231 | 233 | if (capability === "desktop-managed") { |
| 232 | 234 | return yield* failWith( |
| 233 | 235 | "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 |
| 250 | 252 | } |
| 251 | 253 | |
| 252 | 254 | return yield* Effect.gen(function* () { |
| 255 | yield* reportProgress("downloading"); | |
| 253 | 256 | const runtimePaths = yield* ensurePinnedRuntimeInstalled({ |
| 254 | 257 | baseDir: serverConfig.baseDir, |
| 255 | 258 | version: targetVersion, |
| ... | ... | @@ -260,6 +263,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option |
| 260 | 263 | Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)), |
| 261 | 264 | ); |
| 262 | 265 | |
| 266 | yield* reportProgress("installing"); | |
| 263 | 267 | // A broken artifact (failed native build, incompatible node) must be |
| 264 | 268 | // caught while the current server is still alive to report it. |
| 265 | 269 | const preflight = yield* runner |
| ... | ... | @@ -349,38 +353,45 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option |
| 349 | 353 | yield* Effect.logInfo("Server self-update installed; restarting boot service.", { |
| 350 | 354 | targetVersion, |
| 351 | 355 | }); |
| 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)), | |
| 365 | 387 | ), |
| 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 }), | |
| 381 | 392 | ), |
| 382 | Effect.andThen(Effect.fail(restartError)), | |
| 383 | 393 | ), |
| 394 | Effect.ensuring(Ref.set(inFlight, false)), | |
| 384 | 395 | ), |
| 385 | 396 | ); |
| 386 | 397 | } else { |
apps/server/src/environment/ServerEnvironment.ts+3| ... | ... | @@ -144,6 +144,9 @@ export const make = Effect.gen(function* () { |
| 144 | 144 | threadSnooze: true, |
| 145 | 145 | threadTitleRegeneration: true, |
| 146 | 146 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), |
| 147 | ...(serverSelfUpdate === "boot-service" || serverSelfUpdate === "respawn" | |
| 148 | ? { serverSelfUpdateProgress: true } | |
| 149 | : {}), | |
| 147 | 150 | }, |
| 148 | 151 | }; |
| 149 | 152 |
apps/server/src/ws.ts+29| ... | ... | @@ -40,6 +40,8 @@ import { |
| 40 | 40 | ProjectWriteFileError, |
| 41 | 41 | RelayClientInstallFailedError, |
| 42 | 42 | type RelayClientInstallProgressEvent, |
| 43 | type ServerSelfUpdateError, | |
| 44 | type ServerSelfUpdateProgressEvent, | |
| 43 | 45 | type FilesystemBrowseFailure, |
| 44 | 46 | FilesystemBrowseError, |
| 45 | 47 | AssetWorkspaceContextNotFoundError, |
| ... | ... | @@ -1364,6 +1366,33 @@ const makeWsRpcLayer = ( |
| 1364 | 1366 | observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), { |
| 1365 | 1367 | "rpc.aggregate": "server", |
| 1366 | 1368 | }), |
| 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 | ), | |
| 1367 | 1396 | [WS_METHODS.serverUpsertKeybinding]: (rule) => |
| 1368 | 1397 | observeRpcEffect( |
| 1369 | 1398 | WS_METHODS.serverUpsertKeybinding, |
apps/web/src/components/ChatView.tsx+48-25| ... | ... | @@ -296,7 +296,7 @@ import { |
| 296 | 296 | AlertDialogTitle, |
| 297 | 297 | } from "./ui/alert-dialog"; |
| 298 | 298 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 299 | import { ServerUpdateAction } from "./ServerUpdateAction"; | |
| 299 | import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; | |
| 300 | 300 | import { |
| 301 | 301 | buildVersionMismatchDismissalKey, |
| 302 | 302 | dismissVersionMismatch, |
| ... | ... | @@ -1876,12 +1876,16 @@ function ChatViewContent(props: ChatViewProps) { |
| 1876 | 1876 | hasMultipleRegisteredEnvironments && activeThread |
| 1877 | 1877 | ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` |
| 1878 | 1878 | : "server"; |
| 1879 | const versionMismatchEnvironmentId = | |
| 1880 | versionMismatch && activeThread ? activeThread.environmentId : null; | |
| 1879 | const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; | |
| 1881 | 1880 | const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); |
| 1881 | const serverUpdateState = useAtomValue( | |
| 1882 | serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), | |
| 1883 | ); | |
| 1882 | 1884 | const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 1883 | 1885 | const items: ComposerBannerStackItem[] = []; |
| 1884 | if (activeEnvironmentUnavailableState) { | |
| 1886 | const resumingServerUpdate = | |
| 1887 | serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; | |
| 1888 | if (activeEnvironmentUnavailableState && !resumingServerUpdate) { | |
| 1885 | 1889 | const connection = activeEnvironmentUnavailableState.connection; |
| 1886 | 1890 | const isReconnecting = |
| 1887 | 1891 | connection.phase === "connecting" || connection.phase === "reconnecting"; |
| ... | ... | @@ -1918,39 +1922,57 @@ function ChatViewContent(props: ChatViewProps) { |
| 1918 | 1922 | }); |
| 1919 | 1923 | } |
| 1920 | 1924 | if ( |
| 1921 | showVersionMismatchBanner && | |
| 1922 | versionMismatch && | |
| 1923 | versionMismatchDismissKey && | |
| 1924 | versionMismatchEnvironmentId | |
| 1925 | serverUpdateEnvironmentId && | |
| 1926 | (serverUpdateState.status !== "idle" || | |
| 1927 | (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey)) | |
| 1925 | 1928 | ) { |
| 1929 | const updateInProgress = serverUpdateState.status === "running"; | |
| 1930 | const updateFailed = serverUpdateState.status === "failed"; | |
| 1926 | 1931 | items.push({ |
| 1927 | id: `version-mismatch:${versionMismatchDismissKey}`, | |
| 1928 | variant: "warning", | |
| 1932 | id: `server-version:${serverUpdateEnvironmentId}`, | |
| 1933 | variant: updateFailed ? "error" : "warning", | |
| 1929 | 1934 | 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, | |
| 1938 | 1953 | // The desktop-managed guidance is already the description; the action |
| 1939 | 1954 | // slot would only repeat it. |
| 1940 | 1955 | actions: |
| 1956 | updateInProgress || | |
| 1957 | !versionMismatch || | |
| 1941 | 1958 | versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( |
| 1942 | 1959 | <ServerUpdateAction |
| 1943 | environmentId={versionMismatchEnvironmentId} | |
| 1960 | environmentId={serverUpdateEnvironmentId} | |
| 1944 | 1961 | serverLabel={versionMismatchServerLabel} |
| 1945 | 1962 | selfUpdate={versionMismatchSelfUpdate} |
| 1946 | 1963 | targetVersion={versionMismatch.clientVersion} |
| 1964 | {...(updateFailed ? { label: "Retry update" } : {})} | |
| 1947 | 1965 | /> |
| 1948 | 1966 | ), |
| 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 | }), | |
| 1954 | 1976 | }); |
| 1955 | 1977 | } |
| 1956 | 1978 | return items; |
| ... | ... | @@ -1960,9 +1982,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 1960 | 1982 | navigate, |
| 1961 | 1983 | setDismissedVersionMismatchKey, |
| 1962 | 1984 | showVersionMismatchBanner, |
| 1985 | serverUpdateState, | |
| 1963 | 1986 | versionMismatch, |
| 1964 | 1987 | versionMismatchDismissKey, |
| 1965 | versionMismatchEnvironmentId, | |
| 1988 | serverUpdateEnvironmentId, | |
| 1966 | 1989 | versionMismatchSelfUpdate, |
| 1967 | 1990 | versionMismatchServerLabel, |
| 1968 | 1991 | ]); |
apps/web/src/components/ServerUpdateAction.test.tsx+81-134| ... | ... | @@ -1,71 +1,15 @@ |
| 1 | import type { Dispatch, ReactElement, SetStateAction } from "react"; | |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 1 | import type { ReactElement } from "react"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | 4 | import * as Cause from "effect/Cause"; |
| 4 | 5 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 6 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 6 | 7 | |
| 7 | 8 | const testState = vi.hoisted(() => ({ |
| 8 | 9 | updateServer: vi.fn(), |
| 9 | 10 | toast: vi.fn(), |
| 10 | 11 | })); |
| 11 | 12 | |
| 12 | const 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 | ||
| 58 | vi.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 | ||
| 68 | vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); | |
| 69 | 13 | vi.mock("~/hooks/useCopyToClipboard", () => ({ |
| 70 | 14 | useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }), |
| 71 | 15 | })); |
| ... | ... | @@ -79,120 +23,123 @@ vi.mock("./ui/toast", () => ({ |
| 79 | 23 | toastManager: { add: testState.toast }, |
| 80 | 24 | })); |
| 81 | 25 | |
| 82 | import { ServerUpdateAction } from "./ServerUpdateAction"; | |
| 26 | import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; | |
| 83 | 27 | |
| 84 | 28 | type ActionElement = ReactElement<{ |
| 85 | readonly disabled?: boolean; | |
| 86 | 29 | readonly onClick?: () => void; |
| 87 | 30 | }>; |
| 88 | 31 | |
| 89 | 32 | function renderAction(): ActionElement { |
| 90 | hooks.beginRender(); | |
| 91 | 33 | return ServerUpdateAction({ |
| 92 | 34 | environmentId: "env-test" as EnvironmentId, |
| 93 | 35 | serverLabel: "Test server", |
| 94 | 36 | selfUpdate: "boot-service", |
| 95 | targetVersion: "0.0.29", | |
| 37 | targetVersion: "0.0.31", | |
| 96 | 38 | }) as ActionElement; |
| 97 | 39 | } |
| 98 | 40 | |
| 99 | function 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 | ||
| 107 | 41 | async function flushPromises(): Promise<void> { |
| 108 | 42 | await Promise.resolve(); |
| 109 | 43 | await Promise.resolve(); |
| 110 | await Promise.resolve(); | |
| 111 | await Promise.resolve(); | |
| 112 | await Promise.resolve(); | |
| 113 | 44 | } |
| 114 | 45 | |
| 115 | 46 | describe("ServerUpdateAction", () => { |
| 116 | 47 | beforeEach(() => { |
| 117 | vi.useFakeTimers(); | |
| 118 | hooks.reset(); | |
| 119 | 48 | testState.updateServer.mockReset(); |
| 120 | 49 | testState.toast.mockReset(); |
| 121 | 50 | }); |
| 122 | 51 | |
| 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 | ); | |
| 133 | 56 | |
| 134 | 57 | 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 | ); | |
| 144 | 58 | await flushPromises(); |
| 145 | 59 | |
| 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 | }); | |
| 159 | 69 | }); |
| 160 | 70 | |
| 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 | ); | |
| 178 | 82 | |
| 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?.(); | |
| 181 | 86 | |
| 182 | retry.resolve(AsyncResult.success({ targetVersion: "0.0.29", method: "boot-service" })); | |
| 87 | expect(testState.updateServer).toHaveBeenCalledTimes(1); | |
| 88 | finishUpdate?.(); | |
| 183 | 89 | await flushPromises(); |
| 184 | expect(renderAction().props.disabled).toBe(true); | |
| 90 | expect(testState.toast).toHaveBeenCalledTimes(1); | |
| 185 | 91 | }); |
| 186 | 92 | |
| 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 () => { | |
| 188 | 94 | testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt())); |
| 189 | 95 | |
| 190 | 96 | renderAction().props.onClick?.(); |
| 191 | 97 | await flushPromises(); |
| 192 | 98 | |
| 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 | ||
| 103 | describe("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 | />, | |
| 196 | 140 | ); |
| 141 | ||
| 142 | expect(markup).toContain('role="alert"'); | |
| 143 | expect(markup).toContain("The package could not be verified."); | |
| 197 | 144 | }); |
| 198 | 145 | }); |
apps/web/src/components/ServerUpdateAction.tsx+132-117| ... | ... | @@ -1,55 +1,147 @@ |
| 1 | import { useEffect, useRef, useState } from "react"; | |
| 2 | 1 | import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; |
| 2 | import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; | |
| 3 | 3 | import { |
| 4 | 4 | isAtomCommandInterrupted, |
| 5 | 5 | squashAtomCommandFailure, |
| 6 | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import { CheckIcon } from "lucide-react"; | |
| 7 | 8 | |
| 8 | 9 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 10 | import { cn } from "~/lib/utils"; | |
| 9 | 11 | import { serverEnvironment } from "~/state/server"; |
| 10 | 12 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 11 | 13 | import { manualServerUpdateCommand } from "~/versionSkew"; |
| 12 | 14 | import { Button } from "./ui/button"; |
| 13 | import { Spinner } from "./ui/spinner"; | |
| 14 | 15 | import { toastManager } from "./ui/toast"; |
| 15 | 16 | |
| 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 | */ | |
| 21 | const UPDATE_PENDING_EXPIRY_MS = 12 * 60_000; | |
| 17 | const UPDATE_STEPS = [ | |
| 18 | { stage: "downloading", label: "Download" }, | |
| 19 | { stage: "installing", label: "Install" }, | |
| 20 | { stage: "resuming", label: "Resume" }, | |
| 21 | ] as const; | |
| 22 | const pendingUpdateEnvironmentIds = new Set<EnvironmentId>(); | |
| 22 | 23 | |
| 23 | 24 | function updateFailureMessage(error: unknown): string { |
| 24 | 25 | return error instanceof Error ? error.message : "Server update failed."; |
| 25 | 26 | } |
| 26 | 27 | |
| 28 | function 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 | ||
| 45 | export 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 | ||
| 27 | 124 | /** |
| 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. | |
| 34 | 128 | */ |
| 35 | 129 | export function ServerUpdateAction({ |
| 36 | 130 | environmentId, |
| 37 | 131 | serverLabel, |
| 38 | 132 | selfUpdate, |
| 39 | 133 | targetVersion, |
| 134 | label = "Update server", | |
| 40 | 135 | }: { |
| 41 | 136 | readonly environmentId: EnvironmentId; |
| 42 | 137 | readonly serverLabel: string; |
| 43 | 138 | readonly selfUpdate: ServerSelfUpdateCapability | null; |
| 44 | 139 | readonly targetVersion: string; |
| 140 | readonly label?: string; | |
| 45 | 141 | }) { |
| 46 | 142 | const updateServer = useAtomCommand(serverEnvironment.updateServer, { |
| 47 | 143 | reportFailure: false, |
| 48 | 144 | }); |
| 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); | |
| 53 | 145 | const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ |
| 54 | 146 | target: "update command", |
| 55 | 147 | onCopy: ({ command }) => { |
| ... | ... | @@ -68,107 +160,35 @@ export function ServerUpdateAction({ |
| 68 | 160 | }, |
| 69 | 161 | }); |
| 70 | 162 | |
| 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)) { | |
| 87 | 165 | return; |
| 88 | 166 | } |
| 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)) { | |
| 141 | 175 | return; |
| 142 | 176 | } |
| 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; | |
| 155 | 177 | toastManager.add({ |
| 156 | 178 | type: "error", |
| 157 | 179 | title: "Server update failed", |
| 158 | description: updateFailureMessage(error), | |
| 180 | description: updateFailureMessage(squashAtomCommandFailure(result)), | |
| 159 | 181 | }); |
| 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}.`, | |
| 171 | 188 | }); |
| 189 | } finally { | |
| 190 | pendingUpdateEnvironmentIds.delete(environmentId); | |
| 191 | } | |
| 172 | 192 | }; |
| 173 | 193 | |
| 174 | 194 | if (selfUpdate === "desktop-managed") { |
| ... | ... | @@ -188,14 +208,9 @@ export function ServerUpdateAction({ |
| 188 | 208 | ); |
| 189 | 209 | } |
| 190 | 210 | |
| 191 | return pending ? ( | |
| 192 | <Button size="xs" disabled> | |
| 193 | <Spinner className="size-3.5" /> | |
| 194 | Updating... | |
| 195 | </Button> | |
| 196 | ) : ( | |
| 211 | return ( | |
| 197 | 212 | <Button size="xs" onClick={() => void handleUpdate()}> |
| 198 | Update server | |
| 213 | {label} | |
| 199 | 214 | </Button> |
| 200 | 215 | ); |
| 201 | 216 | } |
apps/web/src/components/settings/ConnectionsSettings.tsx+50-13| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | TerminalIcon, |
| 8 | 8 | TriangleAlertIcon, |
| 9 | 9 | } from "lucide-react"; |
| 10 | import { useAtomValue } from "@effect/atom-react"; | |
| 10 | 11 | import { type ReactNode, memo, useCallback, useMemo, useState } from "react"; |
| 11 | 12 | import { |
| 12 | 13 | AuthAccessReadScope, |
| ... | ... | @@ -131,8 +132,9 @@ import { |
| 131 | 132 | usePrimaryEnvironment, |
| 132 | 133 | } from "~/state/environments"; |
| 133 | 134 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 135 | import { serverEnvironment } from "~/state/server"; | |
| 134 | 136 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; |
| 135 | import { ServerUpdateAction } from "../ServerUpdateAction"; | |
| 137 | import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; | |
| 136 | 138 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; |
| 137 | 139 | import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; |
| 138 | 140 | |
| ... | ... | @@ -1389,6 +1391,9 @@ function SavedBackendListRow({ |
| 1389 | 1391 | [copyTraceIdToClipboard], |
| 1390 | 1392 | ); |
| 1391 | 1393 | const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); |
| 1394 | const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); | |
| 1395 | const resumingServerUpdate = | |
| 1396 | serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; | |
| 1392 | 1397 | const sshTarget = |
| 1393 | 1398 | environment.entry.target._tag === "SshConnectionTarget" && |
| 1394 | 1399 | Option.isSome(environment.entry.profile) && |
| ... | ... | @@ -1425,14 +1430,22 @@ function SavedBackendListRow({ |
| 1425 | 1430 | {metadataBits.length > 0 ? ( |
| 1426 | 1431 | <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p> |
| 1427 | 1432 | ) : 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 ? ( | |
| 1429 | 1442 | <p className="flex items-center gap-1 text-warning text-xs"> |
| 1430 | 1443 | <TriangleAlertIcon className="size-3.5 shrink-0" /> |
| 1431 | 1444 | Version drift: client {versionMismatch.clientVersion}, server{" "} |
| 1432 | 1445 | {versionMismatch.serverVersion}. |
| 1433 | 1446 | </p> |
| 1434 | 1447 | ) : null} |
| 1435 | {environment.connection.error ? ( | |
| 1448 | {environment.connection.error && !resumingServerUpdate ? ( | |
| 1436 | 1449 | <p className="flex min-w-0 items-center gap-2 text-destructive text-xs"> |
| 1437 | 1450 | <span className="truncate">{connectionStatusText(environment.connection)}</span> |
| 1438 | 1451 | {errorTraceId ? ( |
| ... | ... | @@ -1448,12 +1461,14 @@ function SavedBackendListRow({ |
| 1448 | 1461 | ) : null} |
| 1449 | 1462 | </div> |
| 1450 | 1463 | <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") ? ( | |
| 1452 | 1466 | <ServerUpdateAction |
| 1453 | 1467 | environmentId={environmentId} |
| 1454 | 1468 | serverLabel={`${environment.label} server`} |
| 1455 | 1469 | selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)} |
| 1456 | 1470 | targetVersion={versionMismatch.clientVersion} |
| 1471 | label={serverUpdateState.status === "failed" ? "Retry update" : "Update server"} | |
| 1457 | 1472 | /> |
| 1458 | 1473 | ) : null} |
| 1459 | 1474 | {isWslEnvironment ? ( |
| ... | ... | @@ -1834,6 +1849,9 @@ export function ConnectionsSettings() { |
| 1834 | 1849 | >(null); |
| 1835 | 1850 | const primaryServerConfig = primaryEnvironment?.serverConfig ?? null; |
| 1836 | 1851 | const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig); |
| 1852 | const primaryServerUpdateState = useAtomValue( | |
| 1853 | serverEnvironment.updateStateAtom(primaryEnvironmentId), | |
| 1854 | ); | |
| 1837 | 1855 | const [isAdvertisedEndpointListExpanded, setIsAdvertisedEndpointListExpanded] = useState(false); |
| 1838 | 1856 | const defaultAdvertisedEndpointKey = useUiStateStore( |
| 1839 | 1857 | (state) => state.defaultAdvertisedEndpointKey, |
| ... | ... | @@ -2980,24 +2998,43 @@ export function ConnectionsSettings() { |
| 2980 | 2998 | {canManageLocalBackend ? ( |
| 2981 | 2999 | <> |
| 2982 | 3000 | <SettingsSection title="This environment"> |
| 2983 | {primaryVersionMismatch ? ( | |
| 3001 | {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( | |
| 2984 | 3002 | <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 | } | |
| 2986 | 3010 | 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 | |
| 2993 | 3025 | } |
| 2994 | 3026 | control={ |
| 2995 | primaryEnvironmentId !== null ? ( | |
| 3027 | primaryVersionMismatch && | |
| 3028 | primaryEnvironmentId !== null && | |
| 3029 | primaryServerUpdateState.status !== "running" ? ( | |
| 2996 | 3030 | <ServerUpdateAction |
| 2997 | 3031 | environmentId={primaryEnvironmentId} |
| 2998 | 3032 | serverLabel={primaryEnvironment?.label ?? "this server"} |
| 2999 | 3033 | selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)} |
| 3000 | 3034 | targetVersion={primaryVersionMismatch.clientVersion} |
| 3035 | {...(primaryServerUpdateState.status === "failed" | |
| 3036 | ? { label: "Retry update" } | |
| 3037 | : {})} | |
| 3001 | 3038 | /> |
| 3002 | 3039 | ) : undefined |
| 3003 | 3040 | } |
docs/architecture/server-updates.md+32-22| ... | ... | @@ -13,8 +13,9 @@ The feature has three boundaries: |
| 13 | 13 | ## Detection and Presentation |
| 14 | 14 | |
| 15 | 15 | `ExecutionEnvironmentDescriptor` includes the server version and an optional |
| 16 | `capabilities.serverSelfUpdate` value. The client compares that version with `APP_VERSION` after | |
| 17 | loading server config. | |
| 16 | `capabilities.serverSelfUpdate` value. Progress-capable servers also advertise | |
| 17 | `capabilities.serverSelfUpdateProgress`. The client compares the server version with `APP_VERSION` | |
| 18 | after loading server config. | |
| 18 | 19 | |
| 19 | 20 | The optional capability is intentionally backward compatible. An older server does not know about |
| 20 | 21 | the 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 |
| 28 | 29 | Both surfaces target the client's exact version. When the reconnected server reports that version, |
| 29 | 30 | the mismatch and action disappear. |
| 30 | 31 | |
| 32 | The operation state lives in `packages/client-runtime`, keyed by environment. Both web surfaces read | |
| 33 | the same `downloading`, `installing`, or `resuming` state, so route changes do not own or cancel the | |
| 34 | operation. | |
| 35 | ||
| 31 | 36 | ## Capability Selection |
| 32 | 37 | |
| 33 | 38 | The server resolves its capability once at startup and publishes it in the environment descriptor. |
| ... | ... | @@ -51,20 +56,25 @@ flowchart TD |
| 51 | 56 | A[Client detects different versions] --> B{Advertised update path} |
| 52 | 57 | B -->|desktop-managed| C[Update desktop app on server machine] |
| 53 | 58 | 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] | |
| 63 | 72 | ``` |
| 64 | 73 | |
| 65 | `server.updateServer` requires the environment's `orchestration:operate` authorization scope. Its | |
| 74 | Both update RPCs require the environment's `orchestration:operate` authorization scope. Their | |
| 66 | 75 | payload 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 | |
| 77 | new client can still repair skew with an older server. | |
| 68 | 78 | |
| 69 | 79 | The update service permits one update at a time. It installs `t3@<version>` under |
| 70 | 80 | `<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. |
| 89 | 99 | ## Process Handoff |
| 90 | 100 | |
| 91 | 101 | For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified |
| 92 | runtime, reloads systemd, and restarts the unit. Reload and restart failures restore the previous | |
| 93 | unit before returning an error. | |
| 102 | runtime and reloads systemd. It acknowledges the handoff, then restarts the unit after the same | |
| 103 | short grace period used by foreground respawn. A rejected deferred restart restores the previous | |
| 104 | unit and is logged by the still-running process. | |
| 94 | 105 | |
| 95 | 106 | For `respawn`, the server starts a detached, delayed replacement that replays the original CLI |
| 96 | 107 | arguments. It then acknowledges the request and schedules the current process to exit. The delays |
| 97 | 108 | give the acknowledgement time to cross direct or relayed connections before the socket closes. |
| 98 | 109 | |
| 99 | There is no separate progress stream. The update request remains pending while npm installs and the | |
| 100 | client shows a disabled update action. A restart can interrupt the request normally; the connection | |
| 101 | runtime keeps the environment registered and reconnects through its usual retry path. After an | |
| 102 | acknowledged foreground handoff, the UI keeps the action pending until version sync removes it or a | |
| 103 | safety timeout releases it. If a boot-service restart closes the connection before acknowledgement, | |
| 104 | the UI releases the interrupted action without reporting a false update failure and lets reconnect | |
| 105 | and the next version check determine the result. | |
| 110 | Progress-capable servers emit `downloading` before installing the pinned runtime and `installing` | |
| 111 | before preflight and handoff. A terminal stream event acknowledges that restart is scheduled. The | |
| 112 | client then enters `resuming`, waits for the replacement lifecycle stream to publish `ready` with | |
| 113 | the target version, and only then completes the operation. It watches for the intentional | |
| 114 | disconnect's first backoff state and requests one fresh retry, which clears historical backoff debt | |
| 115 | without adding a separate reconnect loop. | |
| 106 | 116 | |
| 107 | 117 | ## Release Invariant |
| 108 | 118 |
docs/user/server-updates.md+9-4| ... | ... | @@ -31,6 +31,11 @@ The update does not remove saved threads, settings, or project files. |
| 31 | 31 | The available action depends on how that server was started. T3 Code does not update connected |
| 32 | 32 | servers silently in the background. |
| 33 | 33 | |
| 34 | After 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 | |
| 36 | Connections, so navigating between them does not lose the update. A failed step remains visible | |
| 37 | with its error and an option to retry. | |
| 38 | ||
| 34 | 39 | If the server uses the T3 Code background service, you can also update it directly on the host: |
| 35 | 40 | |
| 36 | 41 | ```sh |
| ... | ... | @@ -42,11 +47,11 @@ commands. |
| 42 | 47 | |
| 43 | 48 | ## After the Update |
| 44 | 49 | |
| 45 | Keep the web or desktop app open while the server restarts. When it reconnects with the matching | |
| 46 | version, the warning and update action disappear. | |
| 50 | Keep the web or desktop app open while the server restarts. The update completes only after the | |
| 51 | replacement server reports the requested version and is ready to accept commands. The warning and | |
| 52 | progress rail then disappear. | |
| 47 | 53 | |
| 48 | If the client reports a timeout, the server may still be finishing the update. Wait a minute, then | |
| 49 | reconnect or open **Settings** → **Connections** again. If the warning remains: | |
| 54 | If a step fails: | |
| 50 | 55 | |
| 51 | 56 | 1. Retry the offered action once. |
| 52 | 57 | 2. 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", () => { |
| 513 | 513 | }), |
| 514 | 514 | ); |
| 515 | 515 | |
| 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 | ||
| 516 | 553 | it.effect("keeps blocked failures idle until an external signal requests another attempt", () => |
| 517 | 554 | Effect.gen(function* () { |
| 518 | 555 | 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* ( |
| 231 | 231 | }; |
| 232 | 232 | const intent = yield* Ref.make(initialIntent); |
| 233 | 233 | const signals = yield* Queue.unbounded<SupervisorSignal>(); |
| 234 | const resetRetryState = yield* Ref.make(false); | |
| 234 | 235 | const state = yield* SubscriptionRef.make<SupervisorConnectionState>( |
| 235 | 236 | !initialIntent.desired |
| 236 | 237 | ? availableState(initialIntent, 0) |
| ... | ... | @@ -643,6 +644,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 643 | 644 | }; |
| 644 | 645 | |
| 645 | 646 | for (;;) { |
| 647 | if (yield* Ref.getAndSet(resetRetryState, false)) { | |
| 648 | failureCount = 0; | |
| 649 | latestFailure = null; | |
| 650 | pendingRetry = Option.none(); | |
| 651 | } | |
| 646 | 652 | const currentIntent = yield* Ref.get(intent); |
| 647 | 653 | if (!currentIntent.desired) { |
| 648 | 654 | resetRetryLadder(); |
| ... | ... | @@ -763,7 +769,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 763 | 769 | Effect.withSpan("EnvironmentSupervisor.disconnect"), |
| 764 | 770 | ); |
| 765 | 771 | |
| 766 | const retryNow = signal({ _tag: "RetryRequested" }).pipe( | |
| 772 | const retryNow = Ref.set(resetRetryState, true).pipe( | |
| 773 | Effect.andThen(signal({ _tag: "RetryRequested" })), | |
| 767 | 774 | Effect.withSpan("EnvironmentSupervisor.retryNow"), |
| 768 | 775 | ); |
| 769 | 776 |
packages/client-runtime/src/rpc/client.ts+2-1| ... | ... | @@ -56,6 +56,7 @@ export type EnvironmentSubscriptionRpcTag = |
| 56 | 56 | |
| 57 | 57 | export type EnvironmentStreamCommandRpcTag = |
| 58 | 58 | | typeof WS_METHODS.cloudInstallRelayClient |
| 59 | | typeof WS_METHODS.serverUpdateServerWithProgress | |
| 59 | 60 | | typeof WS_METHODS.gitRunStackedAction; |
| 60 | 61 | |
| 61 | 62 | export type EnvironmentStreamRpcTag = |
| ... | ... | @@ -80,7 +81,7 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{ |
| 80 | 81 | }), |
| 81 | 82 | }) {} |
| 82 | 83 | |
| 83 | const isRpcClientError = Schema.is(RpcClientError.RpcClientError); | |
| 84 | export const isRpcClientError = Schema.is(RpcClientError.RpcClientError); | |
| 84 | 85 | |
| 85 | 86 | export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0]; |
| 86 | 87 |
packages/client-runtime/src/state/runtime.test.ts+49| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | environmentRpcKey, |
| 14 | 14 | createAtomCommandScheduler, |
| 15 | 15 | createRuntimeCommand, |
| 16 | scheduleAtomCommandEffect, | |
| 16 | 17 | executeAtomCommand, |
| 17 | 18 | executeAtomQuery, |
| 18 | 19 | isAtomCommandInterrupted, |
| ... | ... | @@ -399,6 +400,54 @@ describe("runtime command runner", () => { |
| 399 | 400 | registry.dispose(); |
| 400 | 401 | }); |
| 401 | 402 | |
| 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 | ||
| 402 | 451 | it("deduplicates single-flight commands by key", async () => { |
| 403 | 452 | const latch = Latch.makeUnsafe(); |
| 404 | 453 | let executions = 0; |
packages/client-runtime/src/state/runtime.ts+22| ... | ... | @@ -253,6 +253,28 @@ export function createAtomCommandScheduler(): AtomCommandScheduler { |
| 253 | 253 | }; |
| 254 | 254 | } |
| 255 | 255 | |
| 256 | /** Runs one effect inside an existing command scheduler lane. */ | |
| 257 | export 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 | ||
| 256 | 278 | export async function runAtomCommand<W, A, E>( |
| 257 | 279 | registry: AtomRegistry.AtomRegistry, |
| 258 | 280 | command: AtomCommand<W, A, E>, |
packages/client-runtime/src/state/server.test.ts+131-3| ... | ... | @@ -6,11 +6,15 @@ import { |
| 6 | 6 | WS_METHODS, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import { describe, expect, it } from "@effect/vitest"; |
| 9 | import * as Cause from "effect/Cause"; | |
| 9 | 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Exit from "effect/Exit"; | |
| 10 | 12 | import * as Option from "effect/Option"; |
| 11 | 13 | import * as Queue from "effect/Queue"; |
| 12 | 14 | import * as Stream from "effect/Stream"; |
| 13 | 15 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 16 | import { RpcClientError } from "effect/unstable/rpc"; | |
| 17 | import * as Socket from "effect/unstable/socket/Socket"; | |
| 14 | 18 | |
| 15 | 19 | import { |
| 16 | 20 | AVAILABLE_CONNECTION_STATE, |
| ... | ... | @@ -24,8 +28,12 @@ import type { RpcSession } from "../rpc/session.ts"; |
| 24 | 28 | import { |
| 25 | 29 | applyServerConfigProjection, |
| 26 | 30 | makeEnvironmentServerConfigState, |
| 31 | isLegacyUpdateHandoffLoss, | |
| 27 | 32 | projectServerWelcome, |
| 28 | 33 | resolveServerConfigValue, |
| 34 | resolveServerUpdateProgressResult, | |
| 35 | serverUpdateStateForProgressEvent, | |
| 36 | serverUpdateStateForServerVersion, | |
| 29 | 37 | } from "./server.ts"; |
| 30 | 38 | |
| 31 | 39 | const CONFIG = { |
| ... | ... | @@ -62,6 +70,109 @@ function session(client: WsRpcProtocolClient): RpcSession { |
| 62 | 70 | } |
| 63 | 71 | |
| 64 | 72 | describe("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 | ||
| 65 | 176 | it("applies every config category to the projected snapshot", () => { |
| 66 | 177 | const snapshot = applyServerConfigProjection(Option.none(), { |
| 67 | 178 | version: 1, |
| ... | ... | @@ -100,9 +211,16 @@ describe("server state projection", () => { |
| 100 | 211 | }); |
| 101 | 212 | |
| 102 | 213 | 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"); | |
| 106 | 224 | |
| 107 | 225 | expect( |
| 108 | 226 | resolveServerConfigValue( |
| ... | ... | @@ -114,6 +232,16 @@ describe("server state projection", () => { |
| 114 | 232 | initial, |
| 115 | 233 | ), |
| 116 | 234 | ).toBe(initial); |
| 235 | expect( | |
| 236 | resolveServerConfigValue( | |
| 237 | { | |
| 238 | config: staleLive, | |
| 239 | latestEvent: snapshotEvent(staleLive), | |
| 240 | source: "live", | |
| 241 | }, | |
| 242 | initial, | |
| 243 | ), | |
| 244 | ).toBe(initial); | |
| 117 | 245 | expect( |
| 118 | 246 | resolveServerConfigValue( |
| 119 | 247 | { |
packages/client-runtime/src/state/server.ts+326-9| ... | ... | @@ -3,13 +3,19 @@ import { |
| 3 | 3 | type ServerConfig, |
| 4 | 4 | type ServerConfigStreamEvent, |
| 5 | 5 | type ServerLifecycleWelcomePayload, |
| 6 | type ServerSelfUpdateProgressEvent, | |
| 7 | type ServerSelfUpdateResult, | |
| 6 | 8 | WS_METHODS, |
| 7 | 9 | } from "@t3tools/contracts"; |
| 10 | import * as Cause from "effect/Cause"; | |
| 11 | import * as Duration from "effect/Duration"; | |
| 8 | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; | |
| 9 | 14 | import * as Option from "effect/Option"; |
| 10 | 15 | import * as Queue from "effect/Queue"; |
| 11 | 16 | import * as Ref from "effect/Ref"; |
| 12 | 17 | import * as Result from "effect/Result"; |
| 18 | import * as Schema from "effect/Schema"; | |
| 13 | 19 | import * as Stream from "effect/Stream"; |
| 14 | 20 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 15 | 21 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| ... | ... | @@ -19,14 +25,148 @@ import { |
| 19 | 25 | createEnvironmentRpcCommand, |
| 20 | 26 | createEnvironmentRpcQueryAtomFamily, |
| 21 | 27 | createEnvironmentRpcSubscriptionAtomFamily, |
| 28 | createRuntimeCommand, | |
| 29 | scheduleAtomCommandEffect, | |
| 22 | 30 | } from "./runtime.ts"; |
| 23 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | |
| 31 | import { EnvironmentRegistry } from "../connection/registry.ts"; | |
| 24 | 32 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 25 | 33 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; |
| 26 | 34 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; |
| 27 | import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; | |
| 35 | import { | |
| 36 | isRpcClientError, | |
| 37 | request, | |
| 38 | runStream, | |
| 39 | subscribe, | |
| 40 | type EnvironmentRpcInput, | |
| 41 | } from "../rpc/client.ts"; | |
| 28 | 42 | import { followStreamInEnvironment } from "./runtime.ts"; |
| 29 | 43 | |
| 44 | export type ServerUpdateStage = "downloading" | "installing" | "resuming"; | |
| 45 | ||
| 46 | export 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 | ||
| 62 | export interface ServerUpdateTarget { | |
| 63 | readonly environmentId: EnvironmentId; | |
| 64 | readonly input: EnvironmentRpcInput<typeof WS_METHODS.serverUpdateServer>; | |
| 65 | } | |
| 66 | ||
| 67 | const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" }; | |
| 68 | const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make<ServerUpdateState>(IDLE_SERVER_UPDATE_STATE).pipe( | |
| 69 | Atom.withLabel("environment-data:server:update-state:empty"), | |
| 70 | ); | |
| 71 | const 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 | ||
| 77 | export 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 | ||
| 89 | export 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 | ||
| 100 | export 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 | ||
| 113 | export 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 | ||
| 125 | function serverUpdateFailureMessage(error: unknown): string { | |
| 126 | return error instanceof Error ? error.message : "Server update failed."; | |
| 127 | } | |
| 128 | ||
| 129 | function 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 | ||
| 143 | export 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 | ||
| 153 | export 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 | ||
| 30 | 170 | export interface ServerConfigProjection { |
| 31 | 171 | readonly config: ServerConfig; |
| 32 | 172 | readonly latestEvent: ServerConfigStreamEvent; |
| ... | ... | @@ -225,7 +365,13 @@ export function resolveServerConfigValue( |
| 225 | 365 | projection: ServerConfigProjection | null, |
| 226 | 366 | initialConfig: ServerConfig | null, |
| 227 | 367 | ): 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 | } | |
| 229 | 375 | return initialConfig ?? projection?.config ?? null; |
| 230 | 376 | } |
| 231 | 377 | |
| ... | ... | @@ -238,6 +384,8 @@ export function createServerEnvironmentAtoms<R, E>( |
| 238 | 384 | }, |
| 239 | 385 | ) { |
| 240 | 386 | const configScheduler = createAtomCommandScheduler(); |
| 387 | // Updates stay serial end-to-end, but only their handoff phase occupies the config lane. | |
| 388 | const updateScheduler = createAtomCommandScheduler(); | |
| 241 | 389 | const configConcurrency = { |
| 242 | 390 | mode: "serial" as const, |
| 243 | 391 | key: ({ environmentId }: { readonly environmentId: string }) => environmentId, |
| ... | ... | @@ -271,6 +419,179 @@ export function createServerEnvironmentAtoms<R, E>( |
| 271 | 419 | ); |
| 272 | 420 | }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); |
| 273 | 421 | }); |
| 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 | }); | |
| 274 | 595 | const settingsValueAtom = Atom.family((environmentId: EnvironmentId) => |
| 275 | 596 | Atom.make((get) => get(configValueAtom(environmentId))?.settings ?? null).pipe( |
| 276 | 597 | Atom.withLabel(`environment-data:server:settings:${environmentId}`), |
| ... | ... | @@ -284,6 +605,7 @@ export function createServerEnvironmentAtoms<R, E>( |
| 284 | 605 | |
| 285 | 606 | return { |
| 286 | 607 | configValueAtom, |
| 608 | updateStateAtom, | |
| 287 | 609 | settingsValueAtom, |
| 288 | 610 | providersValueAtom, |
| 289 | 611 | traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { |
| ... | ... | @@ -331,12 +653,7 @@ export function createServerEnvironmentAtoms<R, E>( |
| 331 | 653 | scheduler: configScheduler, |
| 332 | 654 | concurrency: configConcurrency, |
| 333 | 655 | }), |
| 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, | |
| 340 | 657 | upsertKeybinding: createEnvironmentRpcCommand(runtime, { |
| 341 | 658 | label: "environment-data:server:upsert-keybinding", |
| 342 | 659 | tag: WS_METHODS.serverUpsertKeybinding, |
packages/contracts/src/environment.ts+3| ... | ... | @@ -54,6 +54,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 54 | 54 | servers that must be relaunched manually (dev checkouts, Windows |
| 55 | 55 | foreground runs, pre-update servers). */ |
| 56 | 56 | 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), | |
| 57 | 60 | }); |
| 58 | 61 | export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; |
| 59 | 62 |
packages/contracts/src/rpc.ts+13| ... | ... | @@ -127,6 +127,7 @@ import { |
| 127 | 127 | ServerProviderUpdatedPayload, |
| 128 | 128 | ServerSelfUpdateError, |
| 129 | 129 | ServerSelfUpdateInput, |
| 130 | ServerSelfUpdateProgressEvent, | |
| 130 | 131 | ServerSelfUpdateResult, |
| 131 | 132 | ServerTraceDiagnosticsResult, |
| 132 | 133 | ServerProcessDiagnosticsResult, |
| ... | ... | @@ -218,6 +219,7 @@ export const WS_METHODS = { |
| 218 | 219 | serverRefreshProviders: "server.refreshProviders", |
| 219 | 220 | serverUpdateProvider: "server.updateProvider", |
| 220 | 221 | serverUpdateServer: "server.updateServer", |
| 222 | serverUpdateServerWithProgress: "server.updateServerWithProgress", | |
| 221 | 223 | serverUpsertKeybinding: "server.upsertKeybinding", |
| 222 | 224 | serverRemoveKeybinding: "server.removeKeybinding", |
| 223 | 225 | serverGetSettings: "server.getSettings", |
| ... | ... | @@ -305,6 +307,16 @@ export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { |
| 305 | 307 | error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), |
| 306 | 308 | }); |
| 307 | 309 | |
| 310 | export 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 | ||
| 308 | 320 | export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { |
| 309 | 321 | payload: Schema.Struct({}), |
| 310 | 322 | success: ServerSettings, |
| ... | ... | @@ -759,6 +771,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 759 | 771 | WsServerRefreshProvidersRpc, |
| 760 | 772 | WsServerUpdateProviderRpc, |
| 761 | 773 | WsServerUpdateServerRpc, |
| 774 | WsServerUpdateServerWithProgressRpc, | |
| 762 | 775 | WsServerUpsertKeybindingRpc, |
| 763 | 776 | WsServerRemoveKeybindingRpc, |
| 764 | 777 | WsServerGetSettingsRpc, |
packages/contracts/src/server.ts+15| ... | ... | @@ -592,6 +592,21 @@ export const ServerSelfUpdateResult = Schema.Struct({ |
| 592 | 592 | }); |
| 593 | 593 | export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type; |
| 594 | 594 | |
| 595 | export const ServerSelfUpdateProgressStage = Schema.Literals(["downloading", "installing"]); | |
| 596 | export type ServerSelfUpdateProgressStage = typeof ServerSelfUpdateProgressStage.Type; | |
| 597 | ||
| 598 | export 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 | ]); | |
| 608 | export type ServerSelfUpdateProgressEvent = typeof ServerSelfUpdateProgressEvent.Type; | |
| 609 | ||
| 595 | 610 | export class ServerSelfUpdateError extends Schema.TaggedErrorClass<ServerSelfUpdateError>()( |
| 596 | 611 | "ServerSelfUpdateError", |
| 597 | 612 | { |