diff --git a/docs/config/server-access.mdx b/docs/config/server-access.mdx index 3e273d3aa51..aca00a136c8 100644 --- a/docs/config/server-access.mdx +++ b/docs/config/server-access.mdx @@ -78,6 +78,22 @@ Equivalent CLI options: - `--ssh-host ` - `--add-project ` +## Updating the server + +Open **About** (or use the **Check for Updates**, **Download Update**, **Install Update and Restart**, and **Update Channel** command palette actions) to check for updates, download, then choose **Install & restart**. The server uses the saved update channel, or infers Nightly from an installed `-next.` version when no channel is saved. Stable follows the npm `latest` tag; Nightly follows `next`. Switching channels can install an older version. Checks and restarts are manual. The registry must be reachable over HTTPS without credentials (`XUM_UPDATE_REGISTRY_URL` or `npm_config_registry` override the default) and must answer metadata and tarball requests itself: redirects are refused, and registries that require authentication for metadata report the registry error at check time. The server downloads the release tarball and verifies it against the registry's published sha512 digest before the package manager installs it and its dependencies. After the install, every dependency recorded in the staged lockfile is checked against the digest the registry publishes for that exact version, so a dependency the package manager fetched through a redirect or from a tampered mirror is refused. Every request validates the registry certificate, ignoring `strict-ssl=false` and `NODE_TLS_REJECT_UNAUTHORIZED`; trust a private CA by starting the server with `NODE_EXTRA_CA_CERTS`, which the server and the package manager both honor (`cafile` is not consulted). + +Self-update requires a supervisor that restarts the server after it exits, an external launcher symlink pointing to an installed `@coder/xum` CLI with a bun, npm, or pnpm lockfile, and a stable auth token (`MUX_SERVER_AUTH_TOKEN`, `--auth-token`, or `--no-auth`). A generated token dies with the process, so the relaunched server would lock every browser session out. Set `XUM_BINARY` to that symlink and `XUM_SERVER_SUPERVISED=true` only when a supervisor is configured. The `coder/mux` registry module with `restart_on_kill=true` already declares these through its launcher environment. Unsupported installations show a reason instead of offering an update. + +Downloads install an exact package version in a sibling staging directory without changing the running installation. Restart is blocked by active streams, pending turns, workspaces still initializing or being archived, removed, renamed, forked, or staged, workflow runs, project clones and creations, any other request still in flight, queued messages, pending auto-retries, open or starting terminals, live desktop sessions, and running background processes. Finish or stop that work, then retry. There is no automatic restart-when-idle in this version. + +After activation, the server exits gracefully and the supervisor relaunches it. Browser clients reconnect and reload when the server build changes. If reconnection takes longer than about 45 seconds, use **Retry**. + + + The registry module counts self-updates toward `max_restart_attempts`, just like other exits. The + server cannot read the remaining restart budget. Ensure the supervisor has restarts available, or + configure unlimited restarts (`max_restart_attempts=0`) before relying on self-update. + + ## Related - [CLI reference](/reference/cli) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 6fb376dd4c0..8a1befab705 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -99,7 +99,7 @@ import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { ProjectPage } from "@/browser/components/ProjectPage/ProjectPage"; import { SettingsProvider, useSettings } from "./contexts/SettingsContext"; -import { AboutDialogProvider } from "./contexts/AboutDialogContext"; +import { AboutDialogProvider, useAboutDialog } from "./contexts/AboutDialogContext"; import { ConfirmDialogProvider, useConfirmDialog } from "./contexts/ConfirmDialogContext"; import { AboutDialog } from "./features/About/AboutDialog"; import { SettingsPage } from "@/browser/features/Settings/SettingsPage"; @@ -184,6 +184,7 @@ function AppInner() { } = useRouter(); const { themePreference, setTheme, toggleTheme } = useTheme(); const { open: openSettings, isOpen: isSettingsOpen } = useSettings(); + const { open: openAboutDialog } = useAboutDialog(); const { confirm: confirmDialog } = useConfirmDialog(); const setThemePreference = useCallback( (nextTheme: ThemePreference) => { @@ -1019,6 +1020,7 @@ function AppInner() { onToggleTheme: toggleTheme, onSetTheme: setThemePreference, onOpenSettings: openSettings, + onOpenAbout: openAboutDialog, layoutPresets, onApplyLayoutSlot: (workspaceId, slot) => { void applySlotToWorkspace(workspaceId, slot).catch(() => { diff --git a/src/browser/contexts/API.test.tsx b/src/browser/contexts/API.test.tsx index fc76c2d5e94..8247ece4933 100644 --- a/src/browser/contexts/API.test.tsx +++ b/src/browser/contexts/API.test.tsx @@ -1,5 +1,6 @@ +import { VERSION } from "@/version"; import { act, cleanup, render, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; import type { RecursivePartial } from "@/browser/testUtils"; @@ -240,6 +241,67 @@ describe("API reconnection", () => { expect(MockWebSocket.instances).toHaveLength(0); }); + test.each(["changed", "rebuilt", "same", "unreachable", "malformed", "cross-origin"])( + "checks the server version on reconnect: %s", + async (scenario) => { + if (scenario === "cross-origin") process.env.VITE_BACKEND_URL = "https://api.example.com"; + const reload = spyOn(window.location, "reload").mockImplementation(() => undefined); + const requests: string[] = []; + fetchImpl = (input) => { + requests.push( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url + ); + if (scenario === "unreachable") return Promise.reject(new Error("offline")); + return Promise.resolve( + new Response( + JSON.stringify( + scenario === "malformed" + ? {} + : { + git_commit: + scenario === "changed" ? "different-server-commit" : VERSION.git_commit, + git_describe: scenario === "rebuilt" ? "v9.9.9-rebuilt" : VERSION.git_describe, + } + ), + { status: 200 } + ) + ); + }; + window.location.href = "https://coder.example.com/@u/ws/apps/mux/"; + let latestState: UseAPIResult | null = null; + render( + + { + latestState = s.apiState; + }} + /> + + ); + await act(async () => { + MockWebSocket.lastInstance()!.simulateOpen(); + await Promise.resolve(); + }); + expect(latestState!.status).toBe("connected"); + expect(requests).toEqual([]); + act(() => { + latestState!.retry(); + }); + await act(async () => { + MockWebSocket.lastInstance()!.simulateOpen(); + await Promise.resolve(); + }); + expect(requests).toEqual( + scenario === "cross-origin" ? [] : ["https://coder.example.com/@u/ws/apps/mux/version"] + ); + const reloads = scenario === "changed" || scenario === "rebuilt" ? 1 : 0; + expect(reload).toHaveBeenCalledTimes(reloads); + if (reloads === 0) expect(latestState!.status).toBe("connected"); + reload.mockRestore(); + delete process.env.VITE_BACKEND_URL; + } + ); + test("reconnects on close without showing auth_required when previously connected", async () => { const states: string[] = []; diff --git a/src/browser/contexts/API.tsx b/src/browser/contexts/API.tsx index 0194b5ab042..cf4e05e096d 100644 --- a/src/browser/contexts/API.tsx +++ b/src/browser/contexts/API.tsx @@ -1,3 +1,5 @@ +import { SERVER_VERSION_CHECK_TIMEOUT_MS } from "@/constants/serverUpdate"; +import { VERSION } from "@/version"; import { createContext, useContext, @@ -148,6 +150,33 @@ function createBrowserClient( }; } +async function reloadIfServerBuildChanged( + backendBaseUrl: string, + isCurrentConnection: () => boolean +): Promise { + try { + const response = await fetch(`${backendBaseUrl}/version`, { + cache: "no-store", + signal: AbortSignal.timeout(SERVER_VERSION_CHECK_TIMEOUT_MS), + }); + const version: unknown = response.ok ? await response.json() : null; + if ( + isCurrentConnection() && + version && + typeof version === "object" && + "git_commit" in version && + typeof version.git_commit === "string" && + version.git_commit.length > 0 && + (version.git_commit !== VERSION.git_commit || + ("git_describe" in version && version.git_describe !== VERSION.git_describe)) + ) { + window.location.reload(); + } + } catch { + // Version discovery must not disturb an already reconnected client. + } +} + function ManagedAPIProvider(props: Omit) { const [state, setState] = useState({ status: "connecting" }); const [authToken, setAuthToken] = useState(() => { @@ -261,6 +290,7 @@ function ManagedAPIProvider(props: Omit) { return; } + const reconnected = hasConnectedRef.current; authRequiredRef.current = false; hasConnectedRef.current = true; reconnectAttemptRef.current = 0; @@ -269,6 +299,17 @@ function ManagedAPIProvider(props: Omit) { window.__ORPC_CLIENT__ = client; cleanupRef.current = cleanup; setState({ status: "connected", client, cleanup }); + // A reconnected socket may belong to a newer server than this loaded bundle. The probe + // runs after the client is published so a slow /version never delays reconnection, and + // only a bundle served by that server can be refreshed by reloading, so split-origin + // setups (VITE_BACKEND_URL, extension webviews) skip it. + const backendBaseUrl = getBrowserBackendBaseUrl(); + if (reconnected && new URL(backendBaseUrl).origin === window.location.origin) { + void reloadIfServerBuildChanged( + backendBaseUrl, + () => connectionId === connectionIdRef.current + ); + } }) .catch((err: unknown) => { if (connectionId !== connectionIdRef.current) { diff --git a/src/browser/features/About/AboutDialog.stories.tsx b/src/browser/features/About/AboutDialog.stories.tsx new file mode 100644 index 00000000000..26464b39c71 --- /dev/null +++ b/src/browser/features/About/AboutDialog.stories.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "@storybook/test"; +import type { UpdateStatus } from "@/common/orpc/types"; +import { APIProvider } from "@/browser/contexts/API"; +import { AboutDialogProvider, useAboutDialog } from "@/browser/contexts/AboutDialogContext"; +import { Button } from "@/browser/components/Button/Button"; +import { lightweightMeta } from "@/browser/stories/meta"; +import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; +import { AboutDialog } from "./AboutDialog"; + +function OpenAbout() { + const about = useAboutDialog(); + return ( + <> + + + + ); +} + +function ServerUpdateStory(props: { status: UpdateStatus }) { + const [client] = useState(() => + createMockORPCClient({ updateStatus: props.status, updateChannel: "nightly" }) + ); + return ( + + + + + + ); +} + +const meta = { + ...lightweightMeta, + title: "Features/About/Server updates", + component: ServerUpdateStory, + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Open About" })); + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Unsupported: Story = { + args: { + status: { + type: "unsupported", + reason: "Server updates require a supervisor configured to restart after exit", + }, + }, + play: async (context) => { + await meta.play(context); + const dialog = await within(document.body).findByRole("dialog"); + await expect( + within(dialog).queryByRole("button", { name: "Install & restart" }) + ).not.toBeInTheDocument(); + await expect( + within(dialog).queryByRole("button", { name: "Check for Updates" }) + ).not.toBeInTheDocument(); + }, +}; + +export const Downloading: Story = { + args: { status: { type: "downloading", percent: null } }, + play: async (context) => { + await meta.play(context); + const dialog = await within(document.body).findByRole("dialog"); + await expect(within(dialog).getByRole("button", { name: "Check for Updates" })).toBeDisabled(); + }, +}; + +export const BlockedPhone: Story = { + args: { + status: { + type: "install-blocked", + info: { version: "0.28.4-next.123.g123456789" }, + blockers: [ + { kind: "pending-turns", count: 2 }, + { kind: "terminals", count: 1 }, + ], + }, + }, + parameters: { pixel: { matrix: { viewports: ["phone"] } } }, + globals: { viewport: { value: "mobile1", isRotated: false } }, + play: async (context) => { + await meta.play(context); + const dialog = await within(document.body).findByRole("dialog"); + const retry = within(dialog).getByRole("button", { name: "Install & restart" }); + await expect(retry).toBeEnabled(); + await expect(within(dialog).getByRole("status")).toBeVisible(); + if (window.innerWidth <= 440) { + await expect(dialog.getBoundingClientRect().right).toBeLessThanOrEqual(window.innerWidth); + await expect(retry.getBoundingClientRect().right).toBeLessThanOrEqual( + dialog.getBoundingClientRect().right + ); + } + }, +}; diff --git a/src/browser/features/About/AboutDialog.tsx b/src/browser/features/About/AboutDialog.tsx index fc682f054f7..53051310580 100644 --- a/src/browser/features/About/AboutDialog.tsx +++ b/src/browser/features/About/AboutDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Download, Loader2, RefreshCw } from "lucide-react"; import { VERSION } from "@/version"; -import type { UpdateStatus } from "@/common/orpc/types"; +import type { RestartBlocker, UpdateStatus } from "@/common/orpc/types"; import type { UpdateChannel } from "@/common/types/project"; import XumLogoDark from "@/browser/assets/logos/xum-logo-dark.svg?react"; import XumLogoLight from "@/browser/assets/logos/xum-logo-light.svg?react"; @@ -15,6 +15,21 @@ import { ToggleGroupItem, } from "@/browser/components/ToggleGroupPrimitive/ToggleGroupPrimitive"; +const blockerLabels: Record = { + "active-streams": "Active streams", + "pending-turns": "Pending turns", + "workspace-inits": "Workspaces still initializing", + "workspace-lifecycle": "Workspaces being archived, removed, forked, or staged", + workflows: "Workflow runs in progress", + projects: "Projects being cloned or created", + requests: "Requests in flight", + "desktop-sessions": "Live desktop sessions", + "queued-messages": "Sessions with queued messages", + "auto-retries": "Pending auto-retries", + terminals: "Open terminals", + "background-processes": "Running background processes", +}; + interface VersionRecord { buildTime?: unknown; git?: unknown; @@ -75,10 +90,8 @@ export function AboutDialog() { const [pendingAction, setPendingAction] = useState<"check" | "download" | "install" | null>(null); const channelRequestTokenRef = useRef(0); - const isDesktop = typeof window !== "undefined" && Boolean(window.api); - useEffect(() => { - if (!isOpen || !isDesktop || !api) { + if (!isOpen || !api) { return; } @@ -105,10 +118,10 @@ export function AboutDialog() { return () => { controller.abort(); }; - }, [api, isDesktop, isOpen]); + }, [api, isOpen]); useEffect(() => { - if (!isOpen || !isDesktop || !api) { + if (!isOpen || !api) { return; } @@ -128,9 +141,9 @@ export function AboutDialog() { return () => { active = false; }; - }, [api, isDesktop, isOpen]); + }, [api, isOpen]); - const canUseUpdateApi = isDesktop && Boolean(api); + const canUseUpdateApi = Boolean(api); const isChecking = canUseUpdateApi && (updateStatus.type === "checking" || @@ -161,7 +174,7 @@ export function AboutDialog() { api.update .check({ source: "manual" }) .catch(console.error) - // Clear pending if the backend no-ops (e.g. already downloaded) and emits no status event. + // Clear pending if the backend no-ops (e.g. a check is already running) and emits no status event. .finally(() => setPendingAction((prev) => (prev === "check" ? null : prev))); }; @@ -216,12 +229,10 @@ export function AboutDialog() {
Updates
- {!isDesktop ? ( -
- Desktop updates are available in the Electron app only. -
+ {updateStatus.type === "unsupported" ? ( +
{updateStatus.reason}
) : !canUseUpdateApi ? ( -
Connecting to desktop update service…
+
Connecting to update service…
) : ( <> {channel !== null && ( @@ -236,7 +247,7 @@ export function AboutDialog() { handleChannelChange(next); } }} - disabled={channelLoading} + disabled={channelLoading || isChecking || pendingAction !== null} aria-label="Update channel" size="sm" > @@ -271,8 +282,8 @@ export function AboutDialog() { )} {updateStatus.type === "available" && ( -
-
+
+
Update available: {updateStatus.info.version}
)} - {updateStatus.type === "downloaded" && ( -
-
+ {(updateStatus.type === "downloaded" || updateStatus.type === "install-blocked") && ( +
+
Ready to install: {updateStatus.info.version}
)} + {updateStatus.type === "install-blocked" && ( +
+
Finish or stop this work, then retry the restart:
+
    + {updateStatus.blockers.map((blocker) => ( +
  • + {blockerLabels[blocker.kind]}:{" "} + {blocker.count} +
  • + ))} +
+
+ )} + {updateStatus.type === "up-to-date" && (
Xum is up to date.
)} diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index bd940cd2d7a..b1cb7807125 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -32,7 +32,9 @@ import type { ProvidersConfigMap, WorkspaceStatsSnapshot, ServerAuthSession, + UpdateStatus, } from "@/common/orpc/types"; +import type { UpdateChannel } from "@/common/types/project"; import type { ProjectGitStatusResult as ApiProjectGitStatusResult } from "@/common/orpc/schemas/api"; import type { MuxMessage } from "@/common/types/message"; import type { ThinkingLevel } from "@/common/types/thinking"; @@ -189,6 +191,10 @@ export interface MockORPCClientOptions { memoryConsolidationStatus?: MemoryConsolidationStatusPayload; /** Optional file contents for memory.read keyed by virtual path. */ memoryFileContents?: Map; + /** Initial updater status for update.onStatus (About dialog stories). */ + updateStatus?: UpdateStatus; + /** Release channel for update.getChannel. */ + updateChannel?: UpdateChannel; /** Initial route priority for config.getConfig */ routePriority?: string[]; /** Initial per-model route overrides for config.getConfig */ @@ -416,6 +422,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl memoryFiles = [], memoryConsolidationStatus, memoryFileContents = new Map(), + updateStatus, + updateChannel = "stable", routePriority: initialRoutePriority = ["direct"], routeOverrides: initialRouteOverrides = {}, agentDefinitions: initialAgentDefinitions, @@ -1998,10 +2006,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl download: () => Promise.resolve(undefined), install: () => Promise.resolve(undefined), onStatus: async function* () { - yield* []; + if (updateStatus) yield updateStatus; await new Promise(() => undefined); }, - getChannel: () => Promise.resolve("stable" as const), + getChannel: () => Promise.resolve(updateChannel), setChannel: () => Promise.resolve(undefined), }, policy: { diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 81ff199ad92..45f23dab1a8 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -106,6 +106,11 @@ export const CommandIds = { // Help commands helpKeybinds: () => "help:keybinds" as const, + aboutOpen: () => "about:open" as const, + updateCheck: () => "update:check" as const, + updateDownload: () => "update:download" as const, + updateInstall: () => "update:install" as const, + updateChannel: (channel: string) => `update:channel:${channel}` as const, } as const; /** diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index b6ae2b3d289..9ea73f60662 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -508,6 +508,33 @@ test("Login with Coder command opens providers expanded on Coder and starts the }); }); +test("update commands run the operation and open the About dialog, and need an About opener", async () => { + const onOpenAbout = mock(); + const install = mock(() => Promise.resolve()); + let settleChannel!: () => void; + const setChannel = mock( + () => + new Promise((resolve) => { + settleChannel = resolve; + }) + ); + const actions = getActions({ + onOpenAbout, + api: { update: { install, setChannel } } as unknown as APIClient, + }); + await actions.find((a) => a.title === "Install Update and Restart")!.run(); + expect(install).toHaveBeenCalledTimes(1); + expect(onOpenAbout).toHaveBeenCalledTimes(1); + // About reads the channel when it opens, so the switch must persist before the dialog appears. + const switched = actions.find((a) => a.title === "Update Channel: Nightly")!.run(); + expect(setChannel).toHaveBeenCalledWith({ channel: "nightly" }); + expect(onOpenAbout).toHaveBeenCalledTimes(1); + settleChannel(); + await switched; + expect(onOpenAbout).toHaveBeenCalledTimes(2); + expect(getActions().some((a) => a.title === "Check for Updates")).toBe(false); +}); + test("Login with Coder command hides itself when a custom provider shadows the coder id", () => { // Regression: an upgraded install can carry a custom OpenAI-compatible // provider named "coder". ProvidersSection hides the OAuth block for diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index fba9c4fb94e..0d998a54e92 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -147,6 +147,7 @@ export interface BuildSourcesParams { onToggleTheme: () => void; onSetTheme: (theme: ThemePreference) => void; onOpenSettings?: (section?: string, options?: OpenSettingsOptions) => void; + onOpenAbout?: () => void; // Layout slots layoutPresets?: LayoutPresetsConfig | null; @@ -1453,6 +1454,57 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }, ]); + // Updates: the About dialog owns the controls and shows status, blockers, and errors, so each + // command starts the operation and opens the dialog. + if (p.onOpenAbout) { + const openAbout = p.onOpenAbout; + const updateCommand = (operation: (api: APIClient) => Promise) => () => { + if (p.api) void operation(p.api).catch(console.error); + openAbout(); + }; + actions.push(() => [ + { + id: CommandIds.aboutOpen(), + title: "About", + section: section.help, + keywords: ["version", "update", "release"], + run: () => openAbout(), + }, + { + id: CommandIds.updateCheck(), + title: "Check for Updates", + section: section.help, + keywords: ["update", "upgrade", "version"], + run: updateCommand((api) => api.update.check({ source: "manual" })), + }, + { + id: CommandIds.updateDownload(), + title: "Download Update", + section: section.help, + keywords: ["update", "upgrade"], + run: updateCommand((api) => api.update.download()), + }, + { + id: CommandIds.updateInstall(), + title: "Install Update and Restart", + section: section.help, + keywords: ["update", "upgrade", "restart"], + run: updateCommand((api) => api.update.install()), + }, + ...(["stable", "nightly"] as const).map((channel) => ({ + id: CommandIds.updateChannel(channel), + title: `Update Channel: ${channel === "stable" ? "Stable" : "Nightly"}`, + section: section.help, + keywords: ["update", "channel", channel], + // The dialog reads the channel once when it opens, so the change must land first. + run: async () => { + if (p.api) await p.api.update.setChannel({ channel }).catch(console.error); + openAbout(); + }, + })), + ]); + } + // Projects actions.push(() => { const list: CommandAction[] = [ diff --git a/src/cli/server.ts b/src/cli/server.ts index 78dda2f2c4b..5df08333a2d 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -20,6 +20,7 @@ import { validateProjectPath } from "@/node/utils/pathUtils"; import { VERSION } from "@/version"; import { getParseOptions } from "./argv"; import { resolveServerAuthToken } from "./serverAuthToken"; +import { resolveInstallLayout } from "@/node/services/serverUpdate/installLayout"; import { appendServerCrashLogSync } from "./serverCrashLogging"; import { shouldExposeLaunchProject } from "./launchProject"; @@ -261,6 +262,7 @@ async function main(): Promise { }, SERVICE_TEARDOWN_BUDGET_MS); try { + serviceContainer.terminalService.beginShutdown(); // Close all PTY sessions first shutdownStep("terminalService.closeAllSessions", () => serviceContainer.terminalService.closeAllSessions() @@ -293,6 +295,22 @@ async function main(): Promise { } }; + // A generated token dies with this process, so the relaunched server would lock every browser + // session out; self-update is offered only when clients can re-authenticate on their own. + const updateLayout = + resolved.mode === "enabled" && resolved.source === "generated" + ? { + supported: false as const, + reason: + "Server updates require a stable auth token: set MUX_SERVER_AUTH_TOKEN or pass --auth-token", + } + : resolveInstallLayout(process.env, process.argv); + await serviceContainer.updateService.enableServerUpdater(updateLayout, { + refreshBlockers: () => serviceContainer.refreshRestartBlockers(), + collectBlockers: () => serviceContainer.collectRestartBlockers(), + restart: cleanup, + }); + process.on("SIGINT", () => void cleanup()); process.on("SIGTERM", () => void cleanup()); } diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 5d49b6efd6a..fa3c732a04f 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -148,6 +148,7 @@ export { AdvisorOutputEventSchema, AdvisorReasoningOutputEventSchema, AdvisorPhaseEventSchema, + RestartBlockerSchema, UpdateStatusSchema, UsageDeltaEventSchema, WorkspaceChatMessageSchema, diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 63d44f311fa..5e196ec3496 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -728,12 +728,36 @@ export const WorkspaceChatMessageSchema = z.discriminatedUnion("type", [ ]); // Update Status +export const RestartBlockerSchema = z.object({ + kind: z.enum([ + "active-streams", + "pending-turns", + "workspace-inits", + "workspace-lifecycle", + "workflows", + "projects", + "requests", + "desktop-sessions", + "queued-messages", + "auto-retries", + "terminals", + "background-processes", + ]), + count: z.number().int().positive(), +}); + export const UpdateStatusSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("idle") }), z.object({ type: z.literal("checking") }), z.object({ type: z.literal("available"), info: z.object({ version: z.string() }) }), z.object({ type: z.literal("up-to-date") }), - z.object({ type: z.literal("downloading"), percent: z.number() }), + z.object({ type: z.literal("unsupported"), reason: z.string() }), + z.object({ + type: z.literal("install-blocked"), + info: z.object({ version: z.string() }), + blockers: z.array(RestartBlockerSchema), + }), + z.object({ type: z.literal("downloading"), percent: z.number().nullable() }), z.object({ type: z.literal("downloaded"), info: z.object({ version: z.string() }) }), z.object({ type: z.literal("error"), diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts index 85c3e55bd82..b86bf7aa469 100644 --- a/src/common/orpc/types.ts +++ b/src/common/orpc/types.ts @@ -50,6 +50,7 @@ export type DeleteMessage = z.infer; export type GoalBudgetLimitedEvent = z.infer; export type WorkspaceInitEvent = z.infer; export type UpdateStatus = z.infer; +export type RestartBlocker = z.infer; export type DesktopPrereqStatus = z.infer; export type ChatMuxMessage = z.infer; export type WorkspaceStatsSnapshot = z.infer; diff --git a/src/constants/serverUpdate.ts b/src/constants/serverUpdate.ts new file mode 100644 index 00000000000..fd1168b9917 --- /dev/null +++ b/src/constants/serverUpdate.ts @@ -0,0 +1,14 @@ +export const SERVER_UPDATE_CHECK_TIMEOUT_MS = 30_000; +export const SERVER_UPDATE_INSTALL_TIMEOUT_MS = 5 * 60_000; +export const SERVER_UPDATE_SMOKE_TIMEOUT_MS = 30_000; +export const SERVER_UPDATE_STAGING_PREFIX = "xum-staging-"; +export const SERVER_UPDATE_STAGE_MARKER = ".xum-stage.json"; +export const SERVER_UPDATE_LOCKFILES = { + bun: "bun.lock", + npm: "package-lock.json", + pnpm: "pnpm-lock.yaml", +} as const; +export const SERVER_UPDATE_CLI_INTERPRETER = "node"; +export const SERVER_UPDATE_CLI_SHEBANG = `#!/usr/bin/env ${SERVER_UPDATE_CLI_INTERPRETER}`; +export const SERVER_UPDATE_VERIFY_CONCURRENCY = 16; +export const SERVER_VERSION_CHECK_TIMEOUT_MS = 5_000; diff --git a/src/desktop/main.ts b/src/desktop/main.ts index c5e44fe8336..291c1e78ba5 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -1124,8 +1124,7 @@ function createWindow() { void prompt .then(({ response }) => { if (response === 0) { - services?.updateService.install(); - return; + return services?.updateService.install(); } if (response === 1) { diff --git a/src/node/orpc/inFlightProcedures.test.ts b/src/node/orpc/inFlightProcedures.test.ts new file mode 100644 index 00000000000..531a25a60b1 --- /dev/null +++ b/src/node/orpc/inFlightProcedures.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { ORPCError } from "@orpc/server"; +import { inFlightProcedureCount, trackInFlightProcedure } from "./inFlightProcedures"; + +const admit = () => true; +const refuse = () => false; + +describe("in-flight procedure tracking", () => { + test("counts calls for their whole duration and settles on failure too", async () => { + let release!: () => void; + const pending = trackInFlightProcedure( + ["workspace", "remove"], + admit, + () => new Promise((resolve) => (release = resolve)) + ); + expect(inFlightProcedureCount()).toBe(1); + release(); + await pending; + expect(inFlightProcedureCount()).toBe(0); + let failed = false; + try { + await trackInFlightProcedure(["project", "create"], admit, () => + Promise.reject(new Error("boom")) + ); + } catch { + failed = true; + } + expect(failed).toBe(true); + expect(inFlightProcedureCount()).toBe(0); + }); + + test("the install call never blocks its own restart, even once shutdown has begun", async () => { + let release!: () => void; + const pending = trackInFlightProcedure( + ["update", "install"], + refuse, + () => new Promise((resolve) => (release = resolve)) + ); + expect(inFlightProcedureCount()).toBe(0); + release(); + await pending; + }); + + test("refuses every other call once shutdown has begun without running it", async () => { + let ran = false; + let error: unknown; + try { + await trackInFlightProcedure(["workspace", "stageAttachment"], refuse, () => { + ran = true; + return Promise.resolve(); + }); + } catch (caught) { + error = caught; + } + expect(ran).toBe(false); + expect(error).toBeInstanceOf(ORPCError); + expect((error as ORPCError).code).toBe("SERVICE_UNAVAILABLE"); + expect(inFlightProcedureCount()).toBe(0); + }); +}); diff --git a/src/node/orpc/inFlightProcedures.ts b/src/node/orpc/inFlightProcedures.ts new file mode 100644 index 00000000000..c718321c131 --- /dev/null +++ b/src/node/orpc/inFlightProcedures.ts @@ -0,0 +1,45 @@ +import { ORPCError, os } from "@orpc/server"; +import type { ServerService } from "@/node/services/serverService"; + +// Every RPC-driven mutation (project clone/create/remove, workspace rename, archive, ...) is in +// flight for exactly as long as its procedure call, so counting calls gates restarts on all of +// them without enumerating each operation. Subscriptions return their iterator immediately and +// therefore do not pin the count; the install call itself is the restart and is excluded. +let inFlight = 0; + +export function inFlightProcedureCount(): number { + return inFlight; +} + +export async function trackInFlightProcedure( + path: readonly string[], + admit: () => boolean, + run: () => Promise +) { + if (path.join(".") === "update.install") return run(); + // Teardown keeps serving the socket until the very end; a mutation admitted then would be + // killed half-done by the exit, so refuse every new call once shutdown has begun. + if (!admit()) throw new ORPCError("SERVICE_UNAVAILABLE", { message: "Server is shutting down" }); + inFlight++; + try { + return await run(); + } finally { + inFlight--; + } +} + +// oRPC applies builder middlewares at both the router and the procedure level (1.14 dropped the +// leading-middleware dedupe), so the first pass marks the context and the second pass is a no-op. +const TRACKED = "inFlight/tracked"; + +// serverService is optional because unit tests assemble partial contexts without one. +export const inFlightProcedureMiddleware = os + .$context<{ serverService?: Pick; [TRACKED]?: true }>() + .middleware(async ({ context, path, next }) => { + if (context[TRACKED]) return await next(); + return await trackInFlightProcedure( + path, + () => !context.serverService?.isShuttingDown(), + async () => next({ context: { [TRACKED]: true } }) + ); + }); diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index d7fc3b410ff..2f90ff92a62 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -1,12 +1,13 @@ /* eslint-disable @typescript-eslint/await-thenable, @typescript-eslint/no-unsafe-argument, @typescript-eslint/require-await, local/no-sync-fs-methods */ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { createRouterClient } from "@orpc/server"; +import { createRouterClient, ORPCError } from "@orpc/server"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { Config } from "@/node/config"; import type { ORPCContext } from "./context"; +import { inFlightProcedureCount } from "./inFlightProcedures"; import { router } from "./router"; describe("router agent skill routes", () => { @@ -243,4 +244,52 @@ describe("router config transcript mutation", () => { expect((await client.config.getConfig()).chatTranscriptFullWidth).toBe(false); expect(config.loadConfigOrDefault().chatTranscriptFullWidth).toBeUndefined(); }); + + test("refuses procedure calls once the server has begun shutting down", async () => { + let shuttingDown = false; + const context = { + config, + serverService: { isShuttingDown: () => shuttingDown }, + } as unknown as ORPCContext; + const client = createRouterClient(router(), { context }); + expect(await client.general.ping("alive")).toBe("Pong: alive"); + + shuttingDown = true; + let error: unknown; + try { + await client.general.ping("late"); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(ORPCError); + expect((error as ORPCError).code).toBe("SERVICE_UNAVAILABLE"); + }); + + test("an aborted config mutation stays in flight until its write settles", async () => { + let started!: () => void; + const writeStarted = new Promise((resolve) => (started = resolve)); + let finish!: () => void; + const write = new Promise((resolve) => (finish = resolve)); + const context = { + config: { + markSplashScreenViewed: () => { + started(); + return write; + }, + }, + } as unknown as ORPCContext; + const client = createRouterClient(router(), { context }); + const controller = new AbortController(); + const call = client.splashScreens + .markSplashScreenViewed({ splashId: "late" }, { signal: controller.signal }) + .catch((error: unknown) => error); + await writeStarted; + expect(inFlightProcedureCount()).toBe(1); + controller.abort(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(inFlightProcedureCount()).toBe(1); + finish(); + await call; + expect(inFlightProcedureCount()).toBe(0); + }); }); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 02bfc7ae578..3db8393a96b 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -88,6 +88,7 @@ import { extractCookieValues, getFirstHeaderValue, } from "./authMiddleware"; +import { inFlightProcedureMiddleware } from "./inFlightProcedures"; import { clearLogsForApi, getLogFilePath } from "@/node/services/log"; import { @@ -170,8 +171,16 @@ async function getCurrentServerAuthSessionId(context: ORPCContext): Promise(thunk: () => Promise) => Effect.uninterruptible(Effect.promise(thunk)); + export const router = (authToken?: string) => { - const t = os.$context().use(createAuthMiddleware(authToken)); + const t = os + .$context() + .use(createAuthMiddleware(authToken)) + .use(inFlightProcedureMiddleware); return t.router({ tokenizer: { @@ -195,11 +204,10 @@ export const router = (authToken?: string) => { // Config-backed procedures ride handlerGen. Interruption posture (also applies to // the `config` and `uiLayouts` namespaces below): reads are single Effect.sync // steps (interruption is a don't-care); mutations wrap the whole pre-Effect - // handler body in one Effect.promise thunk, so they are uninterruptible by - // construction — a client abort interrupts the handler fiber, never the in-flight - // Semaphore(1)-serialized config edit, and multi-step bodies (mutate + notify) - // cannot be torn apart. Rejections become defects, surfacing as the same internal - // error the old async handlers produced. + // handler body in one atomicPromise thunk, so a client abort never interrupts the + // in-flight Semaphore(1)-serialized config edit, multi-step bodies (mutate + notify) + // cannot be torn apart, and the handler settles only once the write has. Rejections + // become defects, surfacing as the same internal error the old async handlers produced. splashScreens: { getViewedSplashScreens: t .input(schemas.splashScreens.getViewedSplashScreens.input) @@ -217,9 +225,7 @@ export const router = (authToken?: string) => { .output(schemas.splashScreens.markSplashScreenViewed.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => - context.config.markSplashScreenViewed(input.splashId) - ); + yield* atomicPromise(async () => context.config.markSplashScreenViewed(input.splashId)); }) ), }, @@ -290,7 +296,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateAgentAiDefaults.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => + yield* atomicPromise(async () => context.config.updateAgentAiDefaults(input.agentAiDefaults) ); }) @@ -301,7 +307,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateMuxGatewayPrefs.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => { + yield* atomicPromise(async () => { await context.config.updateMuxGatewayPrefs(input); context.providerService.notifyConfigChanged(); }); @@ -312,9 +318,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateRoutePreferences.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => - context.providerService.updateRoutePreferences(input) - ); + yield* atomicPromise(async () => context.providerService.updateRoutePreferences(input)); }) ), @@ -323,7 +327,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateMinThinkingLevels.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => + yield* atomicPromise(async () => context.config.updateMinThinkingLevels(input.minThinkingLevelByModel) ); }) @@ -334,7 +338,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateModelFallbacks.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => + yield* atomicPromise(async () => context.config.updateModelFallbacks(input.modelFallbacks) ); }) @@ -345,7 +349,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateModelPreferences.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => context.config.updateModelPreferences(input)); + yield* atomicPromise(async () => context.config.updateModelPreferences(input)); }) ), @@ -354,7 +358,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateCoderPrefs.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => context.config.updateCoderPrefs(input)); + yield* atomicPromise(async () => context.config.updateCoderPrefs(input)); }) ), updateRuntimeEnablement: t @@ -362,7 +366,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateRuntimeEnablement.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => context.config.updateRuntimeEnablement(input)); + yield* atomicPromise(async () => context.config.updateRuntimeEnablement(input)); }) ), @@ -371,7 +375,7 @@ export const router = (authToken?: string) => { .output(schemas.config.saveConfig.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => { + yield* atomicPromise(async () => { await context.config.saveUserConfig(input); await context.taskService.maybeStartQueuedTasks(); }); @@ -383,7 +387,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateChatTranscriptFullWidth.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => + yield* atomicPromise(async () => context.config.updateChatTranscriptFullWidth(input.enabled) ); }) @@ -393,7 +397,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateLlmDebugLogs.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => context.config.updateLlmDebugLogs(input.enabled)); + yield* atomicPromise(async () => context.config.updateLlmDebugLogs(input.enabled)); }) ), updateHeartbeatDefaultPrompt: t @@ -401,7 +405,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateHeartbeatDefaultPrompt.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => + yield* atomicPromise(async () => context.config.updateHeartbeatDefaultPrompt(input.defaultPrompt) ); }) @@ -411,7 +415,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateHeartbeatDefaultIntervalMs.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => + yield* atomicPromise(async () => context.config.updateHeartbeatDefaultIntervalMs(input.intervalMs) ); }) @@ -421,9 +425,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateGoalDefaults.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => - context.config.updateGoalDefaults(input.goalDefaults) - ); + yield* atomicPromise(async () => context.config.updateGoalDefaults(input.goalDefaults)); }) ), unenrollMuxGovernor: t @@ -431,7 +433,7 @@ export const router = (authToken?: string) => { .output(schemas.config.unenrollMuxGovernor.output) .handler( handlerGen(function* ({ context }) { - yield* Effect.promise(async () => { + yield* atomicPromise(async () => { await context.config.unenrollMuxGovernor(); await context.policyService.refreshNow(); }); @@ -510,9 +512,7 @@ export const router = (authToken?: string) => { .output(schemas.uiLayouts.saveAll.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => - context.config.saveLayoutPresets(input.layoutPresets) - ); + yield* atomicPromise(async () => context.config.saveLayoutPresets(input.layoutPresets)); }) ), }, diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 10d89177343..61c2e42a27a 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4879,6 +4879,22 @@ export const BUILTIN_SKILL_FILES: Record> = { "- `--ssh-host `", "- `--add-project `", "", + "## Updating the server", + "", + "Open **About** (or use the **Check for Updates**, **Download Update**, **Install Update and Restart**, and **Update Channel** command palette actions) to check for updates, download, then choose **Install & restart**. The server uses the saved update channel, or infers Nightly from an installed `-next.` version when no channel is saved. Stable follows the npm `latest` tag; Nightly follows `next`. Switching channels can install an older version. Checks and restarts are manual. The registry must be reachable over HTTPS without credentials (`XUM_UPDATE_REGISTRY_URL` or `npm_config_registry` override the default) and must answer metadata and tarball requests itself: redirects are refused, and registries that require authentication for metadata report the registry error at check time. The server downloads the release tarball and verifies it against the registry's published sha512 digest before the package manager installs it and its dependencies. After the install, every dependency recorded in the staged lockfile is checked against the digest the registry publishes for that exact version, so a dependency the package manager fetched through a redirect or from a tampered mirror is refused. Every request validates the registry certificate, ignoring `strict-ssl=false` and `NODE_TLS_REJECT_UNAUTHORIZED`; trust a private CA by starting the server with `NODE_EXTRA_CA_CERTS`, which the server and the package manager both honor (`cafile` is not consulted).", + "", + "Self-update requires a supervisor that restarts the server after it exits, an external launcher symlink pointing to an installed `@coder/xum` CLI with a bun, npm, or pnpm lockfile, and a stable auth token (`MUX_SERVER_AUTH_TOKEN`, `--auth-token`, or `--no-auth`). A generated token dies with the process, so the relaunched server would lock every browser session out. Set `XUM_BINARY` to that symlink and `XUM_SERVER_SUPERVISED=true` only when a supervisor is configured. The `coder/mux` registry module with `restart_on_kill=true` already declares these through its launcher environment. Unsupported installations show a reason instead of offering an update.", + "", + "Downloads install an exact package version in a sibling staging directory without changing the running installation. Restart is blocked by active streams, pending turns, workspaces still initializing or being archived, removed, renamed, forked, or staged, workflow runs, project clones and creations, any other request still in flight, queued messages, pending auto-retries, open or starting terminals, live desktop sessions, and running background processes. Finish or stop that work, then retry. There is no automatic restart-when-idle in this version.", + "", + "After activation, the server exits gracefully and the supervisor relaunches it. Browser clients reconnect and reload when the server build changes. If reconnection takes longer than about 45 seconds, use **Retry**.", + "", + "", + " The registry module counts self-updates toward `max_restart_attempts`, just like other exits. The", + " server cannot read the remaining restart budget. Ensure the supervisor has restarts available, or", + " configure unlimited restarts (`max_restart_attempts=0`) before relying on self-update.", + "", + "", "## Related", "", "- [CLI reference](/reference/cli)", diff --git a/src/node/services/backgroundProcessManager.ts b/src/node/services/backgroundProcessManager.ts index ead41d842a8..3e6d5834da5 100644 --- a/src/node/services/backgroundProcessManager.ts +++ b/src/node/services/backgroundProcessManager.ts @@ -2381,6 +2381,14 @@ export class BackgroundProcessManager extends EventEmitter boolean) | undefined; /** diff --git a/src/node/services/projectService.test.ts b/src/node/services/projectService.test.ts index 167fe14dc6a..78b1b96170a 100644 --- a/src/node/services/projectService.test.ts +++ b/src/node/services/projectService.test.ts @@ -2195,6 +2195,27 @@ exit 1 } }); + describe("beginShutdown", () => { + it("refuses new project mutations once teardown starts", async () => { + const projectPath = path.join(tempDir, "late-project"); + await fs.mkdir(projectPath); + service.beginShutdown(); + + const outcomes = await Promise.allSettled([ + service.create(projectPath), + service.gitInit(projectPath), + service.remove(projectPath), + service.cloneWithProgress({ repoUrl: "https://example.invalid/repo.git" }).next(), + ]); + expect( + outcomes.map((outcome) => + outcome.status === "rejected" ? String(outcome.reason) : "fulfilled" + ) + ).toEqual(Array(4).fill("Error: Server is shutting down")); + expect(service.getMutationCount()).toBe(0); + }); + }); + describe("gitInit", () => { it("initializes git repo in non-git directory with initial commit", async () => { const testDir = path.join(tempDir, "new-project"); diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index 28a81f401ea..dccd3aad82f 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -397,6 +397,8 @@ export class ProjectService { private readonly fileCompletionsCache = new Map(); /** Canonical paths with git initialization in flight; see create() claim below. */ private readonly activeGitInits = new Set(); + private activeClones = 0; + private shuttingDown = false; private directoryPicker?: (initialPath?: string | null) => Promise; private readonly sshPromptService: SshPromptService | undefined; private workspaceService?: WorkspaceRemover; @@ -475,6 +477,7 @@ export class ProjectService { options: { initGit?: boolean; displayName?: string } | undefined, lock: ProjectRegistrationLockHandle | null ): Promise> { + if (this.shuttingDown) throw new Error("Server is shutting down"); let gitInitClaimKey: string | null = null; try { // Validate input @@ -932,9 +935,32 @@ export class ProjectService { } } + /** Clones and git inits in flight; a restart between them would leave a partial project. */ + getMutationCount(): number { + return this.activeClones + this.activeGitInits.size; + } + + /** A mutation admitted during teardown would be killed half-done by the exit that follows. */ + beginShutdown(): void { + this.shuttingDown = true; + } + async *cloneWithProgress( input: CloneProjectParams, signal?: AbortSignal + ): AsyncGenerator { + if (this.shuttingDown) throw new Error("Server is shutting down"); + this.activeClones++; + try { + yield* this.cloneWithProgressTracked(input, signal); + } finally { + this.activeClones--; + } + } + + private async *cloneWithProgressTracked( + input: CloneProjectParams, + signal?: AbortSignal ): AsyncGenerator { const prepared = this.validateAndPrepareClone(input); if (!prepared.success) { @@ -1347,6 +1373,7 @@ export class ProjectService { } async remove(projectPath: string, force = false): Promise> { + if (this.shuttingDown) throw new Error("Server is shutting down"); try { const normalizedPath = stripTrailingSlashes(projectPath); let config = this.config.loadConfigOrDefault(); @@ -1744,6 +1771,7 @@ export class ProjectService { * Also handles "unborn" repos (git init already run but no commits yet). */ async gitInit(projectPath: string): Promise> { + if (this.shuttingDown) throw new Error("Server is shutting down"); if (typeof projectPath !== "string" || projectPath.trim().length === 0) { return Err("Project path is required"); } diff --git a/src/node/services/serverService.ts b/src/node/services/serverService.ts index 056ecc299b3..45ebe40d30c 100644 --- a/src/node/services/serverService.ts +++ b/src/node/services/serverService.ts @@ -297,6 +297,19 @@ export class ServerService { private serverInfo: ServerInfo | null = null; private readonly mdnsAdvertiser = new MdnsAdvertiserService(); private sshHost: string | undefined = undefined; + private shuttingDown = false; + + /** + * Process teardown has begun. The HTTP/WS server keeps accepting connections until stopServer() + * runs last, so the RPC layer consults this to refuse new procedure calls in the meantime. + */ + beginShutdown(): void { + this.shuttingDown = true; + } + + isShuttingDown(): boolean { + return this.shuttingDown; + } /** * Set the launch project path diff --git a/src/node/services/serverUpdate/activation.ts b/src/node/services/serverUpdate/activation.ts new file mode 100644 index 00000000000..6a0dcf920f4 --- /dev/null +++ b/src/node/services/serverUpdate/activation.ts @@ -0,0 +1,26 @@ +import { lstatSync, realpathSync, renameSync, symlinkSync, unlinkSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { resolveCliEntry, type InstallLayout } from "./installLayout"; + +export function activateUpdate(layout: InstallLayout, stagedEntry: string): void { + if ( + !lstatSync(layout.launcher).isSymbolicLink() || + resolveCliEntry(layout.launcher) !== layout.entry + ) { + throw new Error("Server launcher changed since startup"); + } + // A dangling launcher would brick the next start, so refuse a missing target before the swap. + realpathSync(stagedEntry); + const temporary = `${layout.launcher}.${randomUUID()}.tmp`; + symlinkSync(stagedEntry, temporary); + try { + renameSync(temporary, layout.launcher); + } catch (error) { + try { + unlinkSync(temporary); + } catch { + // Report the failed swap, not the cleanup of its temporary link. + } + throw error; + } +} diff --git a/src/node/services/serverUpdate/installLayout.ts b/src/node/services/serverUpdate/installLayout.ts new file mode 100644 index 00000000000..89f43fdcb54 --- /dev/null +++ b/src/node/services/serverUpdate/installLayout.ts @@ -0,0 +1,146 @@ +import { getErrorMessage } from "@/common/utils/errors"; +import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; +import type { UpdateChannel } from "@/common/types/project"; +import { SERVER_UPDATE_LOCKFILES } from "@/constants/serverUpdate"; + +export interface InstallLayout { + launcher: string; + entry: string; + workdir: string; + packageManager: "bun" | "npm" | "pnpm"; + version: string; + registry: string; +} + +export type LayoutResult = + | { supported: true; layout: InstallLayout } + | { supported: false; reason: string }; + +export function inferChannel(version: string): UpdateChannel { + return version.includes("-next.") ? "nightly" : "stable"; +} + +export function isExactVersion(value: unknown): value is string { + return ( + typeof value === "string" && + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value) + ); +} + +/** + * Real path of the CLI entry a launcher runs. The published `mux` package is a forwarding shim + * (`bin/mux.js` requiring `@coder/xum`), which the registry module installs by default, so the + * shim is followed to the xum entry it forwards to. + */ +export function resolveCliEntry(file: string): string { + const real = realpathSync(file); + const shimPackage = path.dirname(path.dirname(real)); + if (path.basename(path.dirname(real)) === "bin" && readPackageName(shimPackage) === "mux") { + return realpathSync(createRequire(real).resolve("@coder/xum/dist/cli/index.js")); + } + return real; +} + +function readPackageName(packageDir: string): string | undefined { + try { + const pkg: unknown = JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")); + return pkg && typeof pkg === "object" && "name" in pkg && typeof pkg.name === "string" + ? pkg.name + : undefined; + } catch { + return undefined; + } +} + +export function readPackageVersion(packageDir: string): string { + const pkg: unknown = JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")); + if ( + !pkg || + typeof pkg !== "object" || + !("name" in pkg) || + pkg.name !== "@coder/xum" || + !("version" in pkg) || + !isExactVersion(pkg.version) + ) { + throw new Error("Expected an installed @coder/xum package with an exact version"); + } + return pkg.version; +} + +export function resolveInstallLayout( + env: NodeJS.ProcessEnv, + argv: readonly string[], + platform: NodeJS.Platform = process.platform +): LayoutResult { + try { + // Activation replaces the launcher symlink with rename(), which is atomic only on POSIX, and + // the staged entry is checked for a POSIX executable bit. + if (platform === "win32") throw new Error("Server updates are not supported on Windows"); + // coder/mux registry module v1.5 declares its restart loop through RESTART_ON_KILL_VALUE. + const supervised = resolveXumEnvironmentValue("SERVER_SUPERVISED", env); + if (!(/^(1|true|yes)$/i.test(supervised ?? "") || env.RESTART_ON_KILL_VALUE === "true")) { + throw new Error("Server updates require a supervisor configured to restart after exit"); + } + const running = argv[1]; + if (!running) throw new Error("Cannot identify the server launcher"); + // The supervisor relaunches argv[1], so that symlink is the one to re-point: a direct entry + // path would keep running the old version, and a declared binary may only confirm the path. + const launcher = path.resolve(running); + if (!lstatSync(launcher).isSymbolicLink()) + throw new Error("Server must be started through its launcher symlink"); + const declared = resolveXumEnvironmentValue("BINARY", env); + if (declared !== undefined && path.resolve(declared) !== launcher) + throw new Error("Server launcher does not match the declared binary"); + const entry = resolveCliEntry(launcher); + const packageDir = path.dirname(path.dirname(path.dirname(entry))); + if (entry !== path.join(packageDir, "dist", "cli", "index.js")) + throw new Error("Unsupported server entry layout"); + const version = readPackageVersion(packageDir); + let workdir: string | undefined; + let packageManager: InstallLayout["packageManager"] | undefined; + for (let dir = packageDir; path.dirname(dir) !== dir; dir = path.dirname(dir)) { + if (path.basename(dir) !== "node_modules") continue; + const parent = path.dirname(dir); + const managers: Array = []; + // Only the text lockfile is supported: staged dependencies are verified from the lockfile the + // manager writes, and there is no parser for the legacy binary bun.lockb. + if (existsSync(path.join(parent, SERVER_UPDATE_LOCKFILES.bun))) managers.push("bun"); + if (existsSync(path.join(parent, SERVER_UPDATE_LOCKFILES.npm))) managers.push("npm"); + if (existsSync(path.join(parent, SERVER_UPDATE_LOCKFILES.pnpm))) managers.push("pnpm"); + if (managers.length > 1) throw new Error("Ambiguous package manager lockfiles"); + if (managers.length === 1) { + workdir = parent; + packageManager = managers[0]; + break; + } + } + if (!workdir || !packageManager) throw new Error("No supported package manager lockfile found"); + if (launcher.startsWith(workdir + path.sep)) + throw new Error("Server launcher must be outside the package installation"); + const registry = + resolveXumEnvironmentValue("UPDATE_REGISTRY_URL", env) ?? + env.npm_config_registry ?? + "https://registry.npmjs.org"; + const url = new URL(registry); + // The registry is the trust root for the release digest, so it must be TLS-protected; even + // loopback plaintext can be routed through an inherited HTTP proxy. + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) + throw new Error("Unsupported update registry URL"); + return { + supported: true, + layout: { + launcher, + entry, + workdir, + packageManager, + version, + registry: registry.replace(/\/+$/, ""), + }, + }; + } catch (error) { + return { supported: false, reason: getErrorMessage(error) }; + } +} diff --git a/src/node/services/serverUpdate/lockfile.ts b/src/node/services/serverUpdate/lockfile.ts new file mode 100644 index 00000000000..facad2fa4fd --- /dev/null +++ b/src/node/services/serverUpdate/lockfile.ts @@ -0,0 +1,194 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as jsonc from "jsonc-parser"; +import YAML from "yaml"; +import { z } from "zod"; +import { + SERVER_UPDATE_LOCKFILES, + SERVER_UPDATE_VERIFY_CONCURRENCY, +} from "@/constants/serverUpdate"; +import { isExactVersion, type InstallLayout } from "./installLayout"; +import { fetchPublishedDigests, type RegistryRequest } from "./registry"; + +/** + * One package a manager's lockfile records, before any trust decision. `name` is the name the + * dependent requested, never one the served metadata supplied: redirected metadata can call a + * dependency anything and point it at any published tarball, so a manifest-supplied name would let + * the attacker choose which registry digest the entry is compared against. Parsers therefore + * reject aliases (a package installed under another name) outright; the release has none. + */ +interface LockedPackage { + name: string; + version: string; + integrity?: string; + /** Explicit location (URL or local path); absent when derived from the configured registry. */ + resolved?: string; + /** The top-level install of the release tarball, identified by its lockfile key. */ + root: boolean; +} + +/** Splits `name@resolution`; a scoped name keeps its leading `@`. */ +function splitSpec(spec: string): [name: string, resolution: string] { + const at = spec.lastIndexOf("@"); + return at > 0 ? [spec.slice(0, at), spec.slice(at + 1)] : [spec, ""]; +} + +const bunMeta = z.object({}); +const bunLock = z.object({ + packages: z.record( + z.string(), + z.union([ + // Registry releases: spec, tarball URL ("" when derived from the registry), meta, sri. + z + .tuple([z.string(), z.string(), bunMeta, z.string().optional()]) + .transform(([spec, registry, , integrity]) => ({ spec, registry, integrity })), + // Every other resolution kind names its location in the spec. + z.tuple([z.string(), bunMeta]).transform(([spec]) => ({ spec })), + ]) + ), +}); +const npmLock = z.object({ + packages: z.record( + z.string(), + z.object({ + // Present only when the installed package's name differs from its folder. + name: z.string().optional(), + version: z.string().optional(), + resolved: z.string().optional(), + integrity: z.string().optional(), + }) + ), +}); +// Dependency edges: `requested-name: version(peers)` when the package resolved under its own +// name, `requested-name: name@version` (v9) or `/name@version` (v6) when it did not. +const pnpmEdges = z.record(z.string(), z.string()).optional(); +const pnpmLock = z.object({ + packages: z.record( + z.string(), + z.object({ + version: z.string().optional(), + resolution: z.object({ integrity: z.string().optional(), tarball: z.string().optional() }), + dependencies: pnpmEdges, + optionalDependencies: pnpmEdges, + }) + ), + snapshots: z + .record(z.string(), z.object({ dependencies: pnpmEdges, optionalDependencies: pnpmEdges })) + .optional(), +}); + +const lockfileParsers: Record LockedPackage[]> = { + // A bun spec names the package bun asked the registry for (the alias target for an alias), + // never the name the served metadata supplied. + bun: (raw) => + Object.entries(bunLock.parse(jsonc.parse(raw)).packages).map(([key, entry]): LockedPackage => { + const [name, resolution] = splitSpec(entry.spec); + const root = key === "@coder/xum"; + if (!("registry" in entry)) return { name, version: "", resolved: resolution, root }; + return { + name, + version: resolution, + integrity: entry.integrity, + resolved: entry.registry || undefined, + root, + }; + }), + npm: (raw) => + Object.entries(npmLock.parse(JSON.parse(raw)).packages).flatMap(([key, pkg]) => { + if (key === "") return []; + const name = key.slice(key.lastIndexOf("node_modules/") + "node_modules/".length); + if (pkg.name !== undefined && pkg.name !== name) + throw new Error(`Dependency ${name} was installed under another package's name`); + return [ + { + name, + version: pkg.version ?? "", + integrity: pkg.integrity, + resolved: pkg.resolved, + root: key === "node_modules/@coder/xum", + }, + ]; + }), + pnpm: (raw) => { + const lock = pnpmLock.parse(YAML.parse(raw)); + // Package keys carry the name the metadata supplied, so the edges must prove it is also the + // requested one: only a plain version may appear on the right-hand side. + for (const entry of [...Object.values(lock.packages), ...Object.values(lock.snapshots ?? {})]) + for (const [requested, ref] of Object.entries({ + ...entry.dependencies, + ...entry.optionalDependencies, + })) + if (!isExactVersion(ref.replace(/\(.*$/, ""))) + throw new Error(`Dependency ${requested} was installed under another package's name`); + return Object.entries(lock.packages).map(([key, pkg]) => { + // v6 keys are `/name@version(peer@x)`; v9 drops the slash. A local tarball entry carries its + // real version separately. + const [name, resolution] = splitSpec(key.replace(/^\//, "").replace(/\(.*$/, "")); + return { + name, + version: pkg.version ?? resolution, + integrity: pkg.resolution.integrity, + resolved: pkg.resolution.tarball, + root: name === "@coder/xum" && resolution.startsWith("file:"), + }; + }); + }, +}; + +const isLocal = (resolved: string) => + /^(file:|\.\.?\/)/.test(resolved) || path.isAbsolute(resolved); + +/** + * Anchors the staged dependency tree to the configured registry. Managers follow redirects (also + * to plaintext) while resolving dependencies and record whatever digest they were served, so the + * lockfile alone proves nothing; every recorded digest must be one the registry publishes over + * verified HTTPS without redirects. Managers do verify every tarball against its recorded digest, + * which makes that digest the only link that needs anchoring. Returns the verified count. + */ +export async function verifyStagedDependencies( + layout: InstallLayout, + dir: string, + request: RegistryRequest = fetch, + signal?: AbortSignal +): Promise { + const lockfile = path.join(dir, SERVER_UPDATE_LOCKFILES[layout.packageManager]); + const queue: Array<{ name: string; version: string; integrity: string }> = []; + for (const pkg of lockfileParsers[layout.packageManager](await fs.readFile(lockfile, "utf8"))) { + if (pkg.resolved !== undefined && isLocal(pkg.resolved)) { + // The release tarball itself was digest-checked before the install. + if (pkg.root) continue; + throw new Error(`Dependency ${pkg.name} was installed from a local path`); + } + if (pkg.resolved !== undefined && !pkg.resolved.startsWith("https://")) + throw new Error(`Dependency ${pkg.name} was not resolved over HTTPS`); + if (!isExactVersion(pkg.version) || !pkg.integrity) + throw new Error(`Dependency ${pkg.name} is not a digest-pinned registry release`); + queue.push({ name: pkg.name, version: pkg.version, integrity: pkg.integrity }); + } + const total = queue.length; + const worker = async () => { + for (let pkg = queue.shift(); pkg; pkg = queue.shift()) { + try { + const published = await fetchPublishedDigests( + layout.registry, + pkg.name, + pkg.version, + request, + signal + ); + // A manager accepts a tarball matching any recorded digest of its strongest algorithm, so + // one published digest cannot vouch for a foreign one listed beside it. + const recorded = pkg.integrity.trim().split(/\s+/); + if (!recorded.every((sri) => published.includes(sri))) + throw new Error( + `Registry digest for ${pkg.name}@${pkg.version} differs from the staged lockfile` + ); + } catch (error) { + queue.length = 0; + throw error; + } + } + }; + await Promise.all(Array.from({ length: SERVER_UPDATE_VERIFY_CONCURRENCY }, worker)); + return total; +} diff --git a/src/node/services/serverUpdate/registry.ts b/src/node/services/serverUpdate/registry.ts new file mode 100644 index 00000000000..72a228866f6 --- /dev/null +++ b/src/node/services/serverUpdate/registry.ts @@ -0,0 +1,152 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs/promises"; +import { EnvHttpProxyAgent, type Dispatcher } from "undici"; +import { z } from "zod"; +import { + SERVER_UPDATE_CHECK_TIMEOUT_MS, + SERVER_UPDATE_INSTALL_TIMEOUT_MS, +} from "@/constants/serverUpdate"; +import { isExactVersion } from "./installLayout"; + +export type RegistryRequest = (url: string, options: RequestInit) => Promise; + +export interface ReleaseArtifact { + version: string; + tarball: string; + integrity: string; +} + +// Built on first use: a malformed proxy variable must surface as a check error, not crash startup. +let dispatcher: Dispatcher | undefined; + +function requestOptions(signal: AbortSignal): RequestInit & { dispatcher: Dispatcher } { + // NODE_TLS_REJECT_UNAUTHORIZED=0 in the server's environment would otherwise let an on-path + // registry rewrite the tags; explicit options win over that process-wide default, for direct + // (connect) and proxied (requestTls) connections alike. Redirects are refused because a + // redirect target may leave HTTPS; the configured registry must answer every request itself. + dispatcher ??= new EnvHttpProxyAgent({ + connect: { rejectUnauthorized: true }, + requestTls: { rejectUnauthorized: true }, + }); + return { dispatcher, redirect: "error", signal }; +} + +const deadline = (ms: number, signal?: AbortSignal) => + signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms); + +async function fetchJson( + request: RegistryRequest, + url: string, + signal?: AbortSignal +): Promise { + const response = await request( + url, + requestOptions(deadline(SERVER_UPDATE_CHECK_TIMEOUT_MS, signal)) + ); + if (!response.ok) throw new Error(`Registry returned HTTP ${response.status}`); + return response.json(); +} + +export async function fetchDistTags( + registry: string, + request: RegistryRequest = fetch +): Promise<{ latest?: string; next?: string }> { + const tags = await fetchJson(request, `${registry}/-/package/@coder%2Fxum/dist-tags`); + if (!tags || typeof tags !== "object") throw new Error("Invalid registry dist-tags response"); + return { + latest: "latest" in tags && isExactVersion(tags.latest) ? tags.latest : undefined, + next: "next" in tags && isExactVersion(tags.next) ? tags.next : undefined, + }; +} + +const manifestSchema = z.object({ + name: z.string(), + version: z.string(), + dist: z.object({ + tarball: z.string(), + integrity: z + .string() + .regex(/^sha512-[A-Za-z0-9+/]{86}==$/) + .optional(), + shasum: z + .string() + .regex(/^[0-9a-f]{40}$/) + .optional(), + }), +}); + +async function fetchManifest( + registry: string, + name: string, + version: string, + request: RegistryRequest, + signal?: AbortSignal +) { + if (!isExactVersion(version)) throw new Error("Invalid update version"); + const manifest = manifestSchema.safeParse( + await fetchJson(request, `${registry}/${name.replace("/", "%2F")}/${version}`, signal) + ); + if (!manifest.success || manifest.data.name !== name || manifest.data.version !== version) + throw new Error(`Registry manifest for ${name}@${version} is invalid`); + return manifest.data.dist; +} + +export async function fetchArtifact( + registry: string, + version: string, + request: RegistryRequest = fetch, + signal?: AbortSignal +): Promise { + const dist = await fetchManifest(registry, "@coder/xum", version, request, signal); + if (!dist.integrity) + throw new Error("Registry manifest has no verifiable tarball for the requested version"); + const tarball = new URL(dist.tarball); + if (tarball.protocol !== "https:" || tarball.username || tarball.password) + throw new Error("Registry tarball URL is not HTTPS"); + return { version, tarball: tarball.href, integrity: dist.integrity }; +} + +/** The SRI digests the registry publishes for a release, including the legacy sha1 shasum. */ +export async function fetchPublishedDigests( + registry: string, + name: string, + version: string, + request: RegistryRequest = fetch, + signal?: AbortSignal +): Promise { + const dist = await fetchManifest(registry, name, version, request, signal); + const digests = dist.integrity ? [dist.integrity] : []; + if (dist.shasum) digests.push(`sha1-${Buffer.from(dist.shasum, "hex").toString("base64")}`); + return digests; +} + +/** Streams the tarball to `dest` and keeps it only when it matches the manifest's sha512 digest. */ +export async function downloadArtifact( + artifact: ReleaseArtifact, + dest: string, + request: RegistryRequest = fetch, + signal?: AbortSignal +): Promise { + const response = await request( + artifact.tarball, + requestOptions(deadline(SERVER_UPDATE_INSTALL_TIMEOUT_MS, signal)) + ); + if (!response.ok || !response.body) throw new Error(`Registry returned HTTP ${response.status}`); + const hash = createHash("sha512"); + const file = await fs.open(dest, "wx"); + try { + const reader = response.body.getReader(); + for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) { + hash.update(chunk.value); + // A write may persist fewer bytes than offered; the digest must cover what reached disk. + for (let offset = 0; offset < chunk.value.length; ) + offset += (await file.write(chunk.value, offset)).bytesWritten; + } + } finally { + await file.close(); + } + if (`sha512-${hash.digest("base64")}` !== artifact.integrity) { + await fs.rm(dest, { force: true }); + throw new Error("Downloaded update does not match the registry digest"); + } +} diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts new file mode 100644 index 00000000000..90fc04cd536 --- /dev/null +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -0,0 +1,1028 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; +import type { RestartBlocker, UpdateStatus } from "@/common/orpc/types"; +import { resolveInstallLayout, inferChannel, type InstallLayout } from "./installLayout"; +import { activateUpdate } from "./activation"; +import { installCommand, stageUpdate, verifyStagedPackage } from "./staging"; +import { verifyStagedDependencies } from "./lockfile"; +import { + downloadArtifact, + fetchArtifact, + fetchDistTags, + fetchPublishedDigests, + type RegistryRequest, + type ReleaseArtifact, +} from "./registry"; +import { ServerUpdater, type ServerUpdaterDeps } from "./serverUpdater"; +import { SERVER_UPDATE_STAGE_MARKER } from "@/constants/serverUpdate"; +import { createHash } from "node:crypto"; + +const dirs: string[] = []; +afterEach(async () => { + for (const dir of dirs.splice(0)) await fs.rm(dir, { recursive: true, force: true }); +}); + +async function writePackage( + workdir: string, + version: string, + script = "console.log('test version')" +) { + const packageDir = path.join(workdir, "node_modules/@coder/xum"); + const entry = path.join(packageDir, "dist/cli/index.js"); + await fs.mkdir(path.dirname(entry), { recursive: true }); + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "@coder/xum", version }) + ); + await fs.writeFile(entry, `#!/usr/bin/env node\n${script}`, { mode: 0o755 }); + const bin = path.join(workdir, "node_modules/.bin/mux"); + await fs.mkdir(path.dirname(bin), { recursive: true }); + await fs.symlink(entry, bin); + return { entry, bin }; +} + +async function writeMuxShim(workdir: string): Promise { + const shimDir = path.join(workdir, "node_modules/mux"); + await fs.mkdir(path.join(shimDir, "bin"), { recursive: true }); + await fs.writeFile(path.join(shimDir, "package.json"), JSON.stringify({ name: "mux" })); + const shim = path.join(shimDir, "bin/mux.js"); + await fs.writeFile(shim, 'require("@coder/xum/dist/cli/index.js");'); + return shim; +} + +async function fixture( + manager: InstallLayout["packageManager"] = "bun", + version = "1.0.0-next.1", + launcherTarget: "xum" | "shim" = "xum" +) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "server-update-")); + dirs.push(root); + const workdir = path.join(root, "npm"); + const { bin: xumBin } = await writePackage(workdir, version); + const bin = launcherTarget === "shim" ? await writeMuxShim(workdir) : xumBin; + if (manager === "pnpm") { + const packageDir = path.join(workdir, "node_modules/@coder/xum"); + const storeDir = path.join(workdir, "node_modules/.pnpm/xum/node_modules/@coder/xum"); + await fs.mkdir(path.dirname(storeDir), { recursive: true }); + await fs.rename(packageDir, storeDir); + await fs.symlink(storeDir, packageDir); + } + const lockfiles = { bun: "bun.lock", npm: "package-lock.json", pnpm: "pnpm-lock.yaml" }; + await fs.writeFile(path.join(workdir, lockfiles[manager]), ""); + const launcher = path.join(root, "mux"); + await fs.symlink(bin, launcher); + const env = { MUX_BINARY: launcher, RESTART_ON_KILL_VALUE: "true" }; + const argv = ["node", launcher]; + const result = resolveInstallLayout(env, argv); + if (!result.supported) throw new Error(result.reason); + return { root, env, argv, layout: result.layout }; +} + +const sri = (bytes: Uint8Array) => `sha512-${createHash("sha512").update(bytes).digest("base64")}`; +const sriOf = (text: string) => sri(new TextEncoder().encode(text)); +const splitSpec = (spec: string) => { + const at = spec.lastIndexOf("@"); + return { name: spec.slice(0, at), version: spec.slice(at + 1) }; +}; + +/** + * Serves one release (version manifest and tarball) plus the manifests of the dependencies in + * `deps` (`name@version` to sri), recording every request's options. + */ +function fakeRegistry( + version: string, + bytes = new TextEncoder().encode(`tarball ${version}`), + overrides: Partial<{ tarball: string; integrity: string; version: string }> = {}, + deps: Record = {} +) { + const calls: Array<{ url: string; options: RequestInit }> = []; + const tarball = + overrides.tarball ?? `https://registry.example.com/@coder/xum/-/xum-${version}.tgz`; + const json = (body: unknown) => Promise.resolve(new Response(JSON.stringify(body))); + const request: RegistryRequest = (url, options) => { + calls.push({ url, options }); + if (url === tarball) return Promise.resolve(new Response(bytes)); + for (const [spec, integrity] of Object.entries(deps)) { + const { name, version } = splitSpec(spec); + if (url.endsWith(`/${name.replace("/", "%2F")}/${version}`)) + return json({ + name, + version, + dist: { tarball: `https://registry.example.com/${name}/-/${version}.tgz`, integrity }, + }); + } + return json({ + name: "@coder/xum", + version: overrides.version ?? version, + dist: { tarball, integrity: overrides.integrity ?? sri(bytes) }, + }); + }; + return { request, calls, bytes, tarball }; +} + +/** A bun.lock as bun 1.2 writes it: the local release tarball plus registry releases. */ +function bunLock(tarball: string, packages: Record = {}, extra = "") { + const entries = Object.entries(packages).map(([spec, integrity]) => + integrity === null + ? ` "${splitSpec(spec).name}": ["${spec}", {}],` + : ` "${splitSpec(spec).name}": ["${spec}", "", {}, "${integrity}"],` + ); + return [ + "{", + ' "lockfileVersion": 1,', + ` "workspaces": { "": { "dependencies": { "@coder/xum": "${tarball}" } } },`, + ' "packages": {', + ` "@coder/xum": ["@coder/xum@${tarball}", { "dependencies": {} }],`, + ...entries, + extra, + " },", + "}", + "", + ].join("\n"); +} + +/** Stands in for the package manager: writes the staged package and the lockfile bun would. */ +const fakeInstall = + (version: string, packages: Record = {}) => + async (_file: string, _args: string[], cwd: string) => { + await writePackage(cwd, version); + await fs.writeFile( + path.join(cwd, "bun.lock"), + bunLock(path.join(cwd, `xum-${version}.tgz`), packages) + ); + }; + +/** The stage directory a staged CLI entry lives in. */ +const stageOf = (entry: string) => entry.slice(0, entry.indexOf("/node_modules/")); + +/** Names of every directory staged for `version` under `parent`, including foreign ones. */ +const stagesIn = async (parent: string, version: string) => + (await fs.readdir(parent)).filter((name) => name.startsWith(`xum-staging-${version}`)).sort(); + +async function expectFailure(run: () => Promise) { + let failed = false; + try { + await run(); + } catch { + failed = true; + } + expect(failed).toBe(true); +} + +describe("server install layout", () => { + for (const manager of ["bun", "npm", "pnpm"] as const) { + test("recognizes " + manager + " and infers the release channel", async () => { + const { layout } = await fixture(manager); + expect(layout.packageManager).toBe(manager); + expect(layout.version).toBe("1.0.0-next.1"); + expect(inferChannel(layout.version)).toBe("nightly"); + expect(inferChannel("1.0.0")).toBe("stable"); + }); + } + test("follows the published mux shim to the xum entry it forwards to", async () => { + const { layout, root } = await fixture("bun", "1.0.0-next.1", "shim"); + expect(layout.entry).toBe(path.join(root, "npm/node_modules/@coder/xum/dist/cli/index.js")); + expect(layout.version).toBe("1.0.0-next.1"); + const bin = await stageUpdate(layout, "2.0.0", { + ...fakeRegistry("2.0.0"), + install: fakeInstall("2.0.0"), + }); + activateUpdate(layout, bin); + expect(await fs.readlink(layout.launcher)).toBe(bin); + }); + test("requires supervisor, a symlink, and a matching running entry", async () => { + const { env, argv, layout, root } = await fixture(); + expect(resolveInstallLayout({ MUX_BINARY: env.MUX_BINARY }, argv).supported).toBe(false); + expect(resolveInstallLayout(env, argv, "win32").supported).toBe(false); + expect(resolveInstallLayout(env, argv, "linux").supported).toBe(true); + expect( + resolveInstallLayout({ RESTART_ON_KILL_VALUE: "true" }, ["node", layout.entry]).supported + ).toBe(false); + expect( + resolveInstallLayout({ XUM_SERVER_SUPERVISED: "1" }, ["node", layout.launcher]).supported + ).toBe(true); + // A declared launcher does not excuse starting the entry file directly, and it must be the + // symlink the process was started through (the one the supervisor relaunches). + expect(resolveInstallLayout(env, ["node", layout.entry]).supported).toBe(false); + const twin = path.join(root, "twin-mux"); + await fs.symlink(await fs.readlink(layout.launcher), twin); + expect(resolveInstallLayout(env, ["node", twin]).supported).toBe(false); + expect(resolveInstallLayout({ RESTART_ON_KILL_VALUE: "true" }, ["node", twin]).supported).toBe( + true + ); + }); + test("honors canonical environment values and registry precedence", async () => { + const { env, argv, layout } = await fixture(); + const result = resolveInstallLayout( + { + ...env, + XUM_BINARY: layout.launcher, + MUX_BINARY: "/missing", + XUM_UPDATE_REGISTRY_URL: "https://registry.example.com/", + npm_config_registry: "https://ignored.invalid", + }, + argv + ); + expect(result.supported && result.layout.registry).toBe("https://registry.example.com"); + for (const [registry, supported] of [ + ["http://registry.example.com", false], + ["http://127.0.0.1:4873", false], + ["https://registry.example.com:8443/npm", true], + ["https://user:secret@registry.example.com", false], + ] as const) { + expect( + resolveInstallLayout({ ...env, XUM_UPDATE_REGISTRY_URL: registry }, argv).supported + ).toBe(supported); + } + }); + test("fails closed for missing or conflicting lockfiles and malformed package metadata", async () => { + const { env, argv, layout } = await fixture(); + await fs.writeFile(path.join(layout.workdir, "package-lock.json"), "{}"); + expect(resolveInstallLayout(env, argv).supported).toBe(false); + await fs.unlink(path.join(layout.workdir, "package-lock.json")); + await fs.unlink(path.join(layout.workdir, "bun.lock")); + expect(resolveInstallLayout(env, argv).supported).toBe(false); + // The legacy binary lockfile cannot be verified after staging. + await fs.writeFile(path.join(layout.workdir, "bun.lockb"), ""); + expect(resolveInstallLayout(env, argv).supported).toBe(false); + await fs.writeFile(path.join(layout.workdir, "bun.lock"), ""); + expect(resolveInstallLayout(env, argv).supported).toBe(true); + await fs.writeFile(path.join(layout.workdir, "node_modules/@coder/xum/package.json"), "{}"); + expect(resolveInstallLayout(env, argv).supported).toBe(false); + }); +}); + +describe("staging and activation", () => { + test("installs the verified local tarball with lifecycle scripts disabled for every manager", async () => { + const { layout } = await fixture(); + for (const packageManager of ["bun", "npm", "pnpm"] as const) { + const command = installCommand({ ...layout, packageManager }, "/stage/xum-2.0.0.tgz"); + expect(command.file).toBe(packageManager); + expect(command.args).toContain("/stage/xum-2.0.0.tgz"); + expect(command.args).toContain("--ignore-scripts"); + expect(command.args.slice(-2)).toEqual(["--registry", layout.registry]); + } + const npmArgs = installCommand({ ...layout, packageManager: "npm" }, "/x.tgz").args; + expect(npmArgs).toContain("--strict-ssl"); + expect(npmArgs).toContain("--package-lock=true"); + expect(npmArgs).toContain("--include=optional"); + const pnpmArgs = installCommand({ ...layout, packageManager: "pnpm" }, "/x.tgz").args; + expect(pnpmArgs).toContain("--config.strict-ssl=true"); + expect(pnpmArgs).toContain("--config.lockfile=true"); + expect(pnpmArgs).toContain("--config.optional=true"); + expect(installCommand({ ...layout, packageManager: "bun" }, "/x.tgz").args).toContain( + "--save-text-lockfile" + ); + await expectFailure(() => stageUpdate(layout, "../../escape", fakeRegistry("2.0.0"))); + }); + test("stages the digest-checked tarball, refusing a corrupted download before any install", async () => { + const { layout, root } = await fixture(); + const registry = fakeRegistry("2.0.0", undefined, {}, { "zod@4.5.4": sriOf("zod") }); + let installed: string[] = []; + const bin = await stageUpdate(layout, "2.0.0", { + ...registry, + install: async (file, args, cwd) => { + installed = args; + await fakeInstall("2.0.0", { "zod@4.5.4": sriOf("zod") })(file, args, cwd); + }, + }); + const stageDir = stageOf(bin); + expect(path.dirname(stageDir)).toBe(root); + expect(path.basename(stageDir)).toMatch(/^xum-staging-2\.0\.0\./); + const tarball = path.join(stageDir, "xum-2.0.0.tgz"); + expect(installed).toContain(tarball); + expect(new Uint8Array(await fs.readFile(tarball))).toEqual(registry.bytes); + expect(bin).toBe(path.join(stageDir, "node_modules/@coder/xum/dist/cli/index.js")); + expect(registry.calls.map((call) => call.url)).toEqual([ + `${layout.registry}/@coder%2Fxum/2.0.0`, + registry.tarball, + `${layout.registry}/zod/4.5.4`, + ]); + expect(registry.calls.every((call) => call.options.redirect === "error")).toBe(true); + const corrupted = fakeRegistry("3.0.0", undefined, { integrity: sri(new Uint8Array([1])) }); + let installs = 0; + await expectFailure(() => + stageUpdate(layout, "3.0.0", { + ...corrupted, + install: () => { + installs++; + return Promise.resolve(); + }, + }) + ); + expect(installs).toBe(0); + const [aborted] = await stagesIn(root, "3.0.0"); + expect((await fs.readdir(path.join(root, aborted))).sort()).toEqual([ + SERVER_UPDATE_STAGE_MARKER, + "package.json", + ]); + }); + test("prunes only its own old stages, preserves active and original installs, and swaps atomically", async () => { + const { layout, root } = await fixture(); + const oldEntry = await fs.readFile(layout.entry, "utf8"); + const stale = path.join(root, "xum-staging-0.9.0"); + await fs.mkdir(stale); + await fs.writeFile( + path.join(stale, SERVER_UPDATE_STAGE_MARKER), + JSON.stringify({ launcher: layout.launcher }) + ); + // Same naming scheme, but not this updater's: an unmarked directory and another + // installation's stage sharing the parent. + await fs.mkdir(path.join(root, "xum-staging-0.8.0")); + await fs.writeFile(path.join(root, "xum-staging-0.8.0/keep"), ""); + await fs.mkdir(path.join(root, "xum-staging-0.7.0")); + await fs.writeFile( + path.join(root, "xum-staging-0.7.0", SERVER_UPDATE_STAGE_MARKER), + JSON.stringify({ launcher: path.join(root, "other-mux") }) + ); + // A stage that crashed before taking its final name is marked and pruned as well. + const crashed = path.join(root, "xum-staging-1.4.0.a1b2c3"); + await fs.mkdir(crashed); + await fs.writeFile( + path.join(crashed, SERVER_UPDATE_STAGE_MARKER), + JSON.stringify({ launcher: layout.launcher }) + ); + // The updater's own earlier stage carries the marker it wrote and is pruned like the stale one. + await stageUpdate(layout, "1.5.0", { ...fakeRegistry("1.5.0"), install: fakeInstall("1.5.0") }); + const bin = await stageUpdate(layout, "2.0.0", { + ...fakeRegistry("2.0.0"), + install: fakeInstall("2.0.0"), + }); + const remaining = (await fs.readdir(root)).filter((name) => name.startsWith("xum-staging-")); + expect(remaining.sort()).toEqual([ + "xum-staging-0.7.0", + "xum-staging-0.8.0", + path.basename(stageOf(bin)), + ]); + expect(await fs.readdir(path.join(root, "xum-staging-0.8.0"))).toEqual(["keep"]); + activateUpdate(layout, bin); + expect(await fs.readlink(layout.launcher)).toBe(bin); + expect(await fs.readFile(layout.entry, "utf8")).toBe(oldEntry); + const result = resolveInstallLayout( + { MUX_BINARY: layout.launcher, RESTART_ON_KILL_VALUE: "true" }, + ["node", layout.launcher] + ); + if (!result.supported) throw new Error(result.reason); + expect(result.layout.version).toBe("2.0.0"); + await stageUpdate(result.layout, "3.0.0", { + ...fakeRegistry("3.0.0"), + install: fakeInstall("3.0.0"), + }); + expect((await fs.readdir(root)).filter((name) => name.startsWith("xum-staging-"))).toHaveLength( + 4 + ); + expect(await fs.realpath(layout.launcher)).toBe(await fs.realpath(bin)); + expect(await fs.readFile(layout.entry, "utf8")).toBe(oldEntry); + // A foreign directory carrying the version's name, even an empty one that rename() would + // silently replace, is neither touched nor an obstacle. + await fs.mkdir(path.join(root, "xum-staging-4.0.0")); + await stageUpdate(result.layout, "4.0.0", { + ...fakeRegistry("4.0.0"), + install: fakeInstall("4.0.0"), + }); + expect(await fs.readdir(path.join(root, "xum-staging-4.0.0"))).toEqual([]); + expect(await stagesIn(root, "4.0.0")).toHaveLength(2); + }); + test("verification rejects mismatched versions, missing entrypoints, and failing smoke runs", async () => { + const { layout } = await fixture(); + expect(await verifyStagedPackage(layout.workdir, layout.version)).toBe(layout.entry); + await expectFailure(() => verifyStagedPackage(layout.workdir, "9.0.0")); + if (process.platform !== "win32") { + await fs.chmod(layout.entry, 0o644); + await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); + await fs.chmod(layout.entry, 0o755); + } + await fs.writeFile(layout.entry, "console.log('no interpreter line')", { mode: 0o755 }); + await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); + // Any other interpreter line parses but exits 127 under the supervisor. + await fs.writeFile(layout.entry, "#!/definitely/missing\nconsole.log('x')", { mode: 0o755 }); + await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); + await fs.writeFile(layout.entry, "#!/usr/bin/env node\r\nconsole.log('x')", { mode: 0o755 }); + await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); + await fs.writeFile(layout.entry, "#!/usr/bin/env node\nthis is not javascript (", { + mode: 0o755, + }); + await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); + await fs.unlink(layout.entry); + await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); + }); + test("anchors every locked dependency digest to the registry for each manager's lockfile", async () => { + const { layout, root } = await fixture(); + const deps = { "zod@4.5.4": sriOf("zod"), "inner@1.0.0": sriOf("inner") }; + const stage = async (label: string, lockfile: string, raw: string) => { + const dir = path.join(root, `stage-${label}`); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, lockfile), raw); + return dir; + }; + const npmLock = (packages: Record = {}) => + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { dependencies: { "@coder/xum": "file:xum-2.0.0.tgz" } }, + "node_modules/@coder/xum": { version: "2.0.0", resolved: "file:xum-2.0.0.tgz" }, + "node_modules/zod": { + version: "4.5.4", + resolved: "https://registry.example.com/zod/-/zod-4.5.4.tgz", + integrity: deps["zod@4.5.4"], + }, + "node_modules/zod/node_modules/inner": { + version: "1.0.0", + resolved: "https://registry.example.com/inner/-/inner-1.0.0.tgz", + integrity: deps["inner@1.0.0"], + }, + ...packages, + }, + }); + const pnpmLock = (packages: string[] = [], snapshots: string[] = []) => + [ + "lockfileVersion: '9.0'", + "packages:", + " '@coder/xum@file:xum-2.0.0.tgz':", + " resolution: {integrity: sha512-unchecked, tarball: file:xum-2.0.0.tgz}", + " version: 2.0.0", + " zod@4.5.4:", + ` resolution: {integrity: ${deps["zod@4.5.4"]}}`, + " '/inner@1.0.0(zod@4.5.4)':", + ` resolution: {integrity: ${deps["inner@1.0.0"]}}`, + ...packages, + "snapshots:", + " '@coder/xum@file:xum-2.0.0.tgz':", + " dependencies:", + " zod: 4.5.4", + " zod@4.5.4:", + " dependencies:", + " inner: 1.0.0(zod@4.5.4)", + ...snapshots, + "", + ].join("\n"); + const stages = { + bun: await stage("bun", "bun.lock", bunLock("/stage/xum-2.0.0.tgz", deps)), + npm: await stage("npm", "package-lock.json", npmLock()), + pnpm: await stage("pnpm", "pnpm-lock.yaml", pnpmLock()), + }; + for (const packageManager of ["bun", "npm", "pnpm"] as const) { + const registry = fakeRegistry("2.0.0", undefined, {}, deps); + const managerLayout = { ...layout, packageManager }; + const expected = [`${layout.registry}/inner/1.0.0`, `${layout.registry}/zod/4.5.4`]; + expect( + await verifyStagedDependencies(managerLayout, stages[packageManager], registry.request) + ).toBe(expected.length); + expect(registry.calls.map((call) => call.url).sort()).toEqual(expected.sort()); + expect(registry.calls.every((call) => call.options.redirect === "error")).toBe(true); + // A digest the registry does not publish is the redirect-tampering signature. + const tampered = fakeRegistry( + "2.0.0", + undefined, + {}, + { ...deps, "inner@1.0.0": sriOf("evil") } + ); + await expectFailure(() => + verifyStagedDependencies(managerLayout, stages[packageManager], tampered.request) + ); + } + // A published digest listed beside a foreign one must not vouch for it: managers accept a + // tarball matching either. + const mixed = await stage( + "bun", + "bun.lock", + bunLock("/stage/xum-2.0.0.tgz", { + ...deps, + "inner@1.0.0": `${sriOf("evil")} ${deps["inner@1.0.0"]}`, + }) + ); + await expectFailure(() => + verifyStagedDependencies(layout, mixed, fakeRegistry("2.0.0", undefined, {}, deps).request) + ); + const refused = { + plaintext: ` "evil": ["evil@1.0.0", "http://mirror.example.com/evil-1.0.0.tgz", {}, "${sriOf("evil")}"],`, + unpinned: ' "evil": ["evil@1.0.0", "", {}],', + remote: ' "evil": ["evil@https://mirror.example.com/evil-1.0.0.tgz", {}],', + local: ' "evil": ["evil@/tmp/evil.tgz", {}],', + git: ' "evil": ["evil@github:evil/evil#abc", {}],', + }; + for (const extra of Object.values(refused)) { + const dir = await stage("bun", "bun.lock", bunLock("/stage/xum-2.0.0.tgz", deps, extra)); + const registry = fakeRegistry( + "2.0.0", + undefined, + {}, + { ...deps, "evil@1.0.0": sriOf("evil") } + ); + await expectFailure(() => verifyStagedDependencies(layout, dir, registry.request)); + expect(registry.calls).toHaveLength(0); + } + // Redirected metadata can present any published package, or the release tarball itself, as + // the dependency a package requested; only the requested name may be verified, so a lockfile + // recording a different installed name is refused before any registry lookup. + const substituted: Array<[InstallLayout["packageManager"], string]> = [ + [ + "npm", + await stage( + "npm-renamed", + "package-lock.json", + npmLock({ + "node_modules/is-odd": { + name: "is-number", + version: "6.0.0", + resolved: "https://registry.example.com/is-number/-/is-number-6.0.0.tgz", + integrity: sriOf("is-number"), + }, + }) + ), + ], + [ + "npm", + await stage( + "npm-release", + "package-lock.json", + npmLock({ + "node_modules/commander": { + name: "@coder/xum", + version: "2.0.0", + resolved: "file:xum-2.0.0.tgz", + }, + }) + ), + ], + [ + "npm", + await stage( + "npm-nested-release", + "package-lock.json", + npmLock({ + "node_modules/zod/node_modules/@coder/xum": { + version: "2.0.0", + resolved: "file:xum-2.0.0.tgz", + }, + }) + ), + ], + [ + "npm", + await stage( + "npm-bundled", + "package-lock.json", + npmLock({ "node_modules/bundled": { version: "1.0.0", inBundle: true } }) + ), + ], + [ + "pnpm", + await stage( + "pnpm-renamed", + "pnpm-lock.yaml", + pnpmLock( + [" is-number@6.0.0:", ` resolution: {integrity: ${sriOf("is-number")}}`], + [ + " is-number@6.0.0: {}", + " inner@1.0.0(zod@4.5.4):", + " dependencies:", + " is-odd: is-number@6.0.0", + ] + ) + ), + ], + [ + "bun", + await stage( + "bun-release", + "bun.lock", + bunLock( + "/stage/xum-2.0.0.tgz", + deps, + ' "commander": ["@coder/xum@/stage/xum-2.0.0.tgz", {}],' + ) + ), + ], + ]; + for (const [packageManager, dir] of substituted) { + const registry = fakeRegistry( + "2.0.0", + undefined, + {}, + { ...deps, "is-number@6.0.0": sriOf("is-number") } + ); + await expectFailure(() => + verifyStagedDependencies({ ...layout, packageManager }, dir, registry.request) + ); + expect(registry.calls).toHaveLength(0); + } + // A bundled flag is metadata too and exempts nothing: the entry is verified like any other. + const bundled = ` "bundled": ["bundled@1.0.0", "", { "bundled": true }, "${sriOf("bundled")}"],`; + const dir = await stage("bun", "bun.lock", bunLock("/stage/xum-2.0.0.tgz", deps, bundled)); + expect( + await verifyStagedDependencies( + layout, + dir, + fakeRegistry("2.0.0", undefined, {}, { ...deps, "bundled@1.0.0": sriOf("bundled") }).request + ) + ).toBe(3); + const unreadable = await stage("bun", "bun.lock", "not a lockfile"); + await expectFailure(() => + verifyStagedDependencies(layout, unreadable, fakeRegistry("2.0.0").request) + ); + }); + test("activation failure leaves the old link intact", async () => { + const { layout } = await fixture(); + const original = await fs.readlink(layout.launcher); + expect(() => activateUpdate(layout, "/missing-update-bin")).toThrow(); + expect(await fs.readlink(layout.launcher)).toBe(original); + await fs.unlink(layout.launcher); + await fs.writeFile(layout.launcher, "replaced externally"); + expect(() => activateUpdate(layout, layout.entry)).toThrow(); + expect(await fs.readFile(layout.launcher, "utf8")).toBe("replaced externally"); + }); +}); + +describe("server updater", () => { + test("unsupported actions have no effects beyond recording the channel preference", async () => { + const effect = () => { + throw new Error("must not run"); + }; + const updater = new ServerUpdater({ supported: false, reason: "test" }, undefined, { + collectBlockers: effect, + restart: effect, + fetchDistTags: effect, + runInstall: effect, + activate: effect, + }); + await updater.checkForUpdates(); + await updater.downloadUpdate(); + await updater.installUpdate(); + updater.setChannel("nightly"); + expect(updater.getStatus().type).toBe("unsupported"); + expect(updater.getChannel()).toBe("nightly"); + }); + test("selects dist-tags by effective channel, including downgrades, and resets staged updates", async () => { + const { layout } = await fixture("bun", "2.0.0-next.1"); + const deps: ServerUpdaterDeps = { + collectBlockers: () => [], + restart: () => Promise.resolve(), + fetchDistTags: () => Promise.resolve({ latest: "1.0.0", next: layout.version }), + runInstall: () => Promise.resolve("/staged"), + }; + const updater = new ServerUpdater({ supported: true, layout }, undefined, deps); + await updater.checkForUpdates(); + expect(updater.getStatus().type).toBe("up-to-date"); + updater.setChannel("stable"); + await updater.checkForUpdates(); + expect(updater.getStatus()).toEqual({ type: "available", info: { version: "1.0.0" } }); + await updater.downloadUpdate(); + expect(updater.getStatus().type).toBe("downloaded"); + updater.setChannel("nightly"); + expect(updater.getStatus().type).toBe("idle"); + await updater.installUpdate(); + expect(updater.getStatus().type).toBe("idle"); + expect(new ServerUpdater({ supported: true, layout }, "stable", deps).getChannel()).toBe( + "stable" + ); + }); + test("reports check and download failures, suppresses automatic check errors, and retries", async () => { + const { layout } = await fixture(); + let checkFails = true; + let downloadFails = true; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + collectBlockers: () => [], + restart: () => Promise.resolve(), + fetchDistTags: () => + checkFails ? Promise.reject(new Error("offline")) : Promise.resolve({ next: "2.0.0" }), + runInstall: () => + downloadFails ? Promise.reject(new Error("install failed")) : Promise.resolve("/staged"), + }); + const statuses: UpdateStatus[] = []; + updater.subscribe((s) => statuses.push(s)); + await updater.checkForUpdates({ source: "auto" }); + expect(updater.getStatus().type).toBe("idle"); + await updater.checkForUpdates(); + expect(updater.getStatus()).toMatchObject({ type: "error", phase: "check" }); + checkFails = false; + await updater.checkForUpdates(); + await updater.downloadUpdate(); + expect(updater.getStatus()).toMatchObject({ type: "error", phase: "download" }); + downloadFails = false; + await updater.downloadUpdate(); + expect(updater.getStatus().type).toBe("downloaded"); + expect(statuses).toContainEqual({ type: "downloading", percent: null }); + }); + test("blocks volatile work and restarts exactly once only after successful activation", async () => { + const { layout } = await fixture(); + const events: string[] = []; + let blockers: RestartBlocker[] = [{ kind: "terminals", count: 1 }]; + let activationFails = true; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + refreshBlockers: () => { + events.push("refresh"); + return Promise.resolve(); + }, + collectBlockers: () => { + events.push("snapshot"); + return blockers; + }, + restart: () => { + events.push("restart"); + return Promise.resolve(); + }, + fetchDistTags: () => Promise.resolve({ next: "2.0.0" }), + runInstall: () => Promise.resolve("/staged"), + activate: () => { + if (activationFails) throw new Error("failed"); + events.push("activate"); + }, + }); + await updater.checkForUpdates(); + await updater.downloadUpdate(); + await updater.installUpdate(); + expect(updater.getStatus()).toMatchObject({ type: "install-blocked", blockers }); + expect(events).toEqual(["refresh", "snapshot"]); + blockers = []; + events.length = 0; + await updater.installUpdate(); + expect(updater.getStatus()).toMatchObject({ type: "error", phase: "install" }); + expect(events).toEqual(["refresh", "snapshot"]); + activationFails = false; + events.length = 0; + await Promise.all([updater.installUpdate(), updater.installUpdate()]); + expect(events).toEqual(["refresh", "snapshot", "activate", "restart"]); + }); + test("a re-check keeps a staged download the channel still points at and drops a stale one", async () => { + const { layout } = await fixture(); + let next = "2.0.0"; + const events: string[] = []; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + collectBlockers: () => [], + restart: () => Promise.resolve(), + fetchDistTags: () => Promise.resolve({ next }), + runInstall: (_layout, version) => Promise.resolve(`/staged/${version}`), + activate: (_layout, entry) => { + events.push(entry); + throw new Error("failed"); + }, + }); + await updater.checkForUpdates(); + await updater.downloadUpdate(); + await updater.installUpdate(); + expect(updater.getStatus()).toMatchObject({ type: "error", phase: "install" }); + await updater.checkForUpdates(); + expect(updater.getStatus()).toEqual({ type: "downloaded", info: { version: "2.0.0" } }); + next = "2.1.0"; + await updater.checkForUpdates(); + expect(updater.getStatus()).toEqual({ type: "available", info: { version: "2.1.0" } }); + await updater.installUpdate(); + await updater.downloadUpdate(); + await updater.installUpdate(); + expect(events).toEqual(["/staged/2.0.0", "/staged/2.1.0"]); + }); + test("a failed check keeps a staged download installable", async () => { + const { layout } = await fixture(); + let offline = false; + const events: string[] = []; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + collectBlockers: () => [], + restart: () => { + events.push("restart"); + return Promise.resolve(); + }, + fetchDistTags: () => + offline ? Promise.reject(new Error("offline")) : Promise.resolve({ next: "2.0.0" }), + runInstall: () => Promise.resolve("/staged"), + activate: () => { + events.push("activate"); + }, + }); + await updater.checkForUpdates(); + await updater.downloadUpdate(); + offline = true; + await updater.checkForUpdates(); + expect(updater.getStatus()).toEqual({ type: "downloaded", info: { version: "2.0.0" } }); + await updater.installUpdate(); + expect(events).toEqual(["activate", "restart"]); + }); + test("a shutdown that begins while blockers refresh never activates the update", async () => { + const { layout } = await fixture(); + const events: string[] = []; + let releaseRefresh!: () => void; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + refreshBlockers: () => new Promise((resolve) => (releaseRefresh = resolve)), + collectBlockers: () => [], + restart: () => { + events.push("restart"); + return Promise.resolve(); + }, + fetchDistTags: () => Promise.resolve({ next: "2.0.0" }), + runInstall: () => Promise.resolve("/staged"), + activate: () => { + events.push("activate"); + }, + }); + await updater.checkForUpdates(); + await updater.downloadUpdate(); + const install = updater.installUpdate(); + await updater.beginShutdown(); + releaseRefresh(); + await install; + expect(events).toEqual([]); + }); + test("shutdown aborts a pending stage and waits for it to settle", async () => { + const { layout } = await fixture(); + let observed: AbortSignal | undefined; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + collectBlockers: () => [], + restart: () => Promise.resolve(), + fetchDistTags: () => Promise.resolve({ next: "2.0.0" }), + runInstall: (_layout, _version, options) => + new Promise((_resolve, reject) => { + observed = options?.signal; + observed?.addEventListener("abort", () => reject(new Error("aborted"))); + }), + }); + await updater.checkForUpdates(); + const download = updater.downloadUpdate(); + expect(updater.getStatus()).toMatchObject({ type: "downloading" }); + await updater.beginShutdown(); + await download; + expect(observed?.aborted).toBe(true); + expect(updater.getStatus()).toMatchObject({ type: "error", phase: "download" }); + observed = undefined; + await updater.downloadUpdate(); + expect(observed).toBeUndefined(); + }); + test("serializes checks and downloads and refuses channel changes while busy", async () => { + const { layout } = await fixture(); + let resolveTags!: (tags: { next: string }) => void; + let checks = 0; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + collectBlockers: () => [], + restart: () => Promise.resolve(), + fetchDistTags: () => { + checks++; + return new Promise((resolve) => { + resolveTags = resolve; + }); + }, + }); + const check = updater.checkForUpdates(); + await updater.checkForUpdates(); + expect(checks).toBe(1); + expect(() => updater.setChannel("stable")).toThrow(); + resolveTags({ next: layout.version }); + await check; + expect(updater.getStatus().type).toBe("up-to-date"); + }); +}); + +describe("registry discovery", () => { + test("requests scoped package dist-tags and accepts only exact versions", async () => { + let observedUrl = ""; + let hasSignal = false; + const tags = await fetchDistTags("https://registry.example.com/prefix", (url, options) => { + observedUrl = url; + hasSignal = options.signal instanceof AbortSignal; + return Promise.resolve( + new Response(JSON.stringify({ latest: "1.0.0", next: "../../invalid" })) + ); + }); + expect(observedUrl).toBe( + "https://registry.example.com/prefix/-/package/@coder%2Fxum/dist-tags" + ); + expect(hasSignal).toBe(true); + expect(tags).toEqual({ latest: "1.0.0", next: undefined }); + }); + test("resolves a release to its HTTPS tarball and sha512 digest without following redirects", async () => { + const registry = fakeRegistry("2.0.0"); + const artifact = await fetchArtifact("https://registry.example.com", "2.0.0", registry.request); + expect(artifact).toEqual({ + version: "2.0.0", + tarball: registry.tarball, + integrity: sri(registry.bytes), + }); + expect(registry.calls).toHaveLength(1); + expect(registry.calls[0].url).toBe("https://registry.example.com/@coder%2Fxum/2.0.0"); + expect(registry.calls[0].options.redirect).toBe("error"); + const rejected = [ + fakeRegistry("2.0.0", undefined, { tarball: "http://registry.example.com/xum-2.0.0.tgz" }), + fakeRegistry("2.0.0", undefined, { tarball: "https://user:pw@registry.example.com/x.tgz" }), + fakeRegistry("2.0.0", undefined, { integrity: "sha1-2jmj7l5rSw0yVb/vlWAYkK/YBwk=" }), + fakeRegistry("2.0.0", undefined, { version: "2.0.1" }), + ]; + for (const registry of rejected) + await expectFailure(() => + fetchArtifact("https://registry.example.com", "2.0.0", registry.request) + ); + await expectFailure(() => + fetchArtifact("https://registry.example.com", "latest", fakeRegistry("2.0.0").request) + ); + }); + test("keeps a downloaded tarball only when it matches the digest", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "server-update-")); + dirs.push(root); + const registry = fakeRegistry("2.0.0"); + const artifact: ReleaseArtifact = { + version: "2.0.0", + tarball: registry.tarball, + integrity: sri(registry.bytes), + }; + const dest = path.join(root, "xum-2.0.0.tgz"); + await downloadArtifact(artifact, dest, registry.request); + expect(new Uint8Array(await fs.readFile(dest))).toEqual(registry.bytes); + expect(registry.calls[0].options.redirect).toBe("error"); + await expectFailure(() => downloadArtifact(artifact, dest, registry.request)); + const tampered = path.join(root, "tampered.tgz"); + await expectFailure(() => + downloadArtifact( + { ...artifact, integrity: sri(new Uint8Array([1])) }, + tampered, + registry.request + ) + ); + expect(await fs.readdir(root)).toEqual(["xum-2.0.0.tgz"]); + await expectFailure(() => + downloadArtifact(artifact, tampered, () => Promise.resolve(new Response("", { status: 404 }))) + ); + }); + test("finishes writing a chunk the file handle only partially accepted", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "server-update-")); + dirs.push(dir); + interface Writer { + write: ( + buffer: Uint8Array, + offset?: number, + length?: number + ) => Promise<{ bytesWritten: number }>; + } + const probe = await fs.open(path.join(dir, "probe"), "w"); + const proto = Object.getPrototypeOf(probe) as Writer; + await probe.close(); + const write = proto.write; + let shortened = 0; + const spy = spyOn(proto, "write").mockImplementation(function ( + this: Writer, + buffer, + offset = 0, + length + ) { + // Persist a single byte the first time a multi-byte chunk arrives. + if (shortened === 0 && buffer.length - offset > 1) { + shortened++; + return write.call(this, buffer, offset, 1); + } + return write.call(this, buffer, offset, length); + }); + try { + const registry = fakeRegistry( + "2.0.0", + new TextEncoder().encode("a tarball with several bytes") + ); + const artifact = await fetchArtifact( + "https://registry.example.com", + "2.0.0", + registry.request + ); + const dest = path.join(dir, "xum.tgz"); + await downloadArtifact(artifact, dest, registry.request); + expect(shortened).toBe(1); + expect(new Uint8Array(await fs.readFile(dest))).toEqual(registry.bytes); + } finally { + spy.mockRestore(); + } + }); + test("the stage's abort signal reaches the manifest request", async () => { + const registry = fakeRegistry("2.0.0"); + const abort = new AbortController(); + abort.abort(); + await fetchArtifact("https://registry.example.com", "2.0.0", registry.request, abort.signal); + expect(registry.calls[0].options.signal?.aborted).toBe(true); + await fetchArtifact("https://registry.example.com", "2.0.0", registry.request); + expect(registry.calls[1].options.signal?.aborted).toBe(false); + }); + test("publishes a release's sha512 digest and legacy sha1 shasum, for the named package only", async () => { + const shasum = "0123456789abcdef0123456789abcdef01234567"; + const manifest = (name: string) => + Promise.resolve( + new Response( + JSON.stringify({ + name, + version: "1.0.0", + dist: { tarball: "https://r.example.com/x.tgz", integrity: sriOf("x"), shasum }, + }) + ) + ); + expect( + await fetchPublishedDigests("https://r.example.com", "x", "1.0.0", () => manifest("x")) + ).toEqual([sriOf("x"), `sha1-${Buffer.from(shasum, "hex").toString("base64")}`]); + await expectFailure(() => + fetchPublishedDigests("https://r.example.com", "x", "1.0.0", () => manifest("y")) + ); + }); + test("rejects HTTP errors and malformed responses", async () => { + await expectFailure(() => + fetchDistTags("https://registry.example.com", () => + Promise.resolve(new Response("", { status: 503 })) + ) + ); + await expectFailure(() => + fetchDistTags("https://registry.example.com", () => Promise.resolve(new Response("not-json"))) + ); + }); +}); diff --git a/src/node/services/serverUpdate/serverUpdater.ts b/src/node/services/serverUpdate/serverUpdater.ts new file mode 100644 index 00000000000..b017608683e --- /dev/null +++ b/src/node/services/serverUpdate/serverUpdater.ts @@ -0,0 +1,197 @@ +import { getErrorMessage } from "@/common/utils/errors"; +import type { RestartBlocker, UpdateStatus } from "@/common/orpc/types"; +import type { UpdateChannel } from "@/common/types/project"; +import { log } from "@/node/services/log"; +import { activateUpdate } from "./activation"; +import { + inferChannel, + isExactVersion, + type InstallLayout, + type LayoutResult, +} from "./installLayout"; +import { fetchDistTags } from "./registry"; +import { stageUpdate } from "./staging"; + +export interface ServerUpdaterDeps { + /** Refreshes lazily updated blocker sources; the snapshot that follows stays synchronous. */ + refreshBlockers?: () => Promise; + collectBlockers: () => RestartBlocker[]; + restart: () => Promise; + fetchDistTags?: typeof fetchDistTags; + runInstall?: typeof stageUpdate; + activate?: typeof activateUpdate; +} + +export class ServerUpdater { + private status: UpdateStatus; + private channel: UpdateChannel; + private readonly layout: InstallLayout | null; + private readonly subscribers = new Set<(status: UpdateStatus) => void>(); + private availableVersion: string | null = null; + private staged: { entry: string; version: string } | null = null; + private installing = false; + private shuttingDown = false; + private download: { abort: AbortController; settled: Promise } | null = null; + + constructor( + result: LayoutResult, + channel: UpdateChannel | undefined, + private readonly deps: ServerUpdaterDeps + ) { + this.layout = result.supported ? result.layout : null; + this.channel = channel ?? inferChannel(this.layout?.version ?? ""); + this.status = result.supported + ? { type: "idle" } + : { type: "unsupported", reason: result.reason }; + } + + getStatus(): UpdateStatus { + return this.status; + } + getChannel(): UpdateChannel { + return this.channel; + } + + subscribe(callback: (status: UpdateStatus) => void): () => void { + this.subscribers.add(callback); + callback(this.status); + return () => this.subscribers.delete(callback); + } + + private setStatus(status: UpdateStatus): void { + this.status = status; + for (const callback of this.subscribers) { + try { + callback(status); + } catch (error) { + log.error("Server update subscriber failed", error); + } + } + } + + setChannel(channel: UpdateChannel): void { + if (channel === this.channel) return; + if (this.installing || this.status.type === "checking" || this.status.type === "downloading") + throw new Error("An update operation is in progress"); + // An unsupported layout still records the preference so it applies once the operator has + // met the reported requirement and restarted. + this.channel = channel; + if (!this.layout) return; + this.availableVersion = null; + this.staged = null; + this.setStatus({ type: "idle" }); + } + + async checkForUpdates(options?: { source?: "auto" | "manual" }): Promise { + if ( + !this.layout || + this.shuttingDown || + this.installing || + this.status.type === "checking" || + this.status.type === "downloading" + ) + return; + const previous = this.status; + this.setStatus({ type: "checking" }); + try { + const tags = await (this.deps.fetchDistTags ?? fetchDistTags)(this.layout.registry); + const version = tags[this.channel === "stable" ? "latest" : "next"]; + if (!isExactVersion(version)) + throw new Error("Registry has no valid version for the selected channel"); + this.availableVersion = version === this.layout.version ? null : version; + // A staged download stays installable while the channel still points at it, so a re-check + // after a failed install returns to the ready state instead of discarding the download. + if (this.staged && this.staged.version !== this.availableVersion) this.staged = null; + this.setStatus( + this.staged + ? { type: "downloaded", info: { version } } + : this.availableVersion + ? { type: "available", info: { version } } + : { type: "up-to-date" } + ); + } catch (error) { + // A verified stage stays installable while the registry is unreachable; the dialog offers no + // install action on a check error. + if (this.staged) { + log.warn("Update check failed; the staged update remains installable", error); + this.setStatus({ type: "downloaded", info: { version: this.staged.version } }); + return; + } + this.setStatus( + options?.source === "auto" + ? previous + : { type: "error", phase: "check", message: getErrorMessage(error) } + ); + } + } + + async downloadUpdate(): Promise { + if ( + !this.layout || + this.shuttingDown || + !this.availableVersion || + this.staged || + this.installing || + this.status.type === "checking" || + this.status.type === "downloading" + ) + return; + this.setStatus({ type: "downloading", percent: null }); + const abort = new AbortController(); + const download = { + abort, + settled: this.stage(this.layout, this.availableVersion, abort.signal), + }; + this.download = download; + await download.settled; + if (this.download === download) this.download = null; + } + + private async stage(layout: InstallLayout, version: string, signal: AbortSignal): Promise { + try { + const entry = await (this.deps.runInstall ?? stageUpdate)(layout, version, { signal }); + this.staged = { entry, version }; + this.setStatus({ type: "downloaded", info: { version } }); + } catch (error) { + this.setStatus({ type: "error", phase: "download", message: getErrorMessage(error) }); + } + } + + /** A detached package manager must not outlive the server and keep writing into the stage. */ + async beginShutdown(): Promise { + this.shuttingDown = true; + this.download?.abort.abort(); + await this.download?.settled; + } + + async installUpdate(): Promise { + if (!this.layout || this.shuttingDown || !this.staged || this.installing) return; + const staged = this.staged; + this.installing = true; + try { + await this.deps.refreshBlockers?.(); + // An unrelated teardown (SIGTERM) may have begun during the refresh; it must not inherit + // the launcher swap. + if (this.shuttingDown) { + this.installing = false; + return; + } + const blockers = this.deps.collectBlockers(); + if (blockers.length) { + this.installing = false; + this.setStatus({ + type: "install-blocked", + info: { version: staged.version }, + blockers, + }); + return; + } + // No await between the idle snapshot, atomic swap, and the CLI's shutdown latch. + (this.deps.activate ?? activateUpdate)(this.layout, staged.entry); + await this.deps.restart(); + } catch (error) { + this.installing = false; + this.setStatus({ type: "error", phase: "install", message: getErrorMessage(error) }); + } + } +} diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts new file mode 100644 index 00000000000..1b9d7ddf7bb --- /dev/null +++ b/src/node/services/serverUpdate/staging.ts @@ -0,0 +1,181 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { z } from "zod"; +import { execFileAsync } from "@/node/utils/disposableExec"; +import { + SERVER_UPDATE_CLI_INTERPRETER, + SERVER_UPDATE_CLI_SHEBANG, + SERVER_UPDATE_INSTALL_TIMEOUT_MS, + SERVER_UPDATE_SMOKE_TIMEOUT_MS, + SERVER_UPDATE_STAGE_MARKER, + SERVER_UPDATE_STAGING_PREFIX, +} from "@/constants/serverUpdate"; +import { + isExactVersion, + readPackageVersion, + resolveCliEntry, + type InstallLayout, +} from "./installLayout"; +import { verifyStagedDependencies } from "./lockfile"; +import { downloadArtifact, fetchArtifact, type RegistryRequest } from "./registry"; + +/** Installs an already verified local tarball; only its dependencies come from the registry. */ +export function installCommand( + layout: InstallLayout, + tarball: string +): { file: string; args: string[] } { + // CLI flags outrank npmrc files and npm_config_* env, so an inherited strict-ssl=false cannot + // disable certificate validation for the download, an inherited package-lock=false or + // lockfile=false cannot suppress the lockfile that dependency verification reads, and an + // inherited omit of the optional platform packages loses to npm's include and pnpm's + // optional=true. bun has no such flags; its TLS and lockfile knobs are env variables runInstall + // strips, while a global bunfig that disables lockfile saving still fails closed at + // verification and one that disables optional dependencies has no override. The text lockfile + // is required, so an older bun that only writes bun.lockb must fail here. + const flags = { + bun: ["add", "--ignore-scripts", "--save-text-lockfile"], + npm: [ + "install", + "--no-global", + "--no-audit", + "--no-fund", + "--omit=dev", + "--ignore-scripts", + "--strict-ssl", + "--package-lock=true", + "--include=optional", + ], + pnpm: [ + "add", + "--no-global", + "--ignore-scripts", + "--config.strict-ssl=true", + "--config.lockfile=true", + "--config.optional=true", + ], + } satisfies Record; + return { + file: layout.packageManager, + args: [...flags[layout.packageManager], tarball, "--registry", layout.registry], + }; +} + +export async function verifyStagedPackage( + dir: string, + version: string, + signal?: AbortSignal +): Promise { + const packageDir = path.join(dir, "node_modules/@coder/xum"); + if (readPackageVersion(packageDir) !== version) + throw new Error("Staged package version does not match the requested update"); + const entry = path.join(packageDir, "dist/cli/index.js"); + const stat = await fs.stat(entry); + if (!stat.isFile()) throw new Error("Staged CLI entry is not a file"); + // The supervisor execs the launcher symlink directly, so the entry must be executable and name + // the interpreter the running install uses; a parseable file with any other first line would + // fail every relaunch attempt. + const shebang = `${SERVER_UPDATE_CLI_SHEBANG}\n`; + const handle = await fs.open(entry); + try { + const { buffer, bytesRead } = await handle.read( + Buffer.alloc(shebang.length), + 0, + shebang.length, + 0 + ); + if (buffer.subarray(0, bytesRead).toString() !== shebang) + throw new Error("Staged CLI entry does not start with the expected interpreter line"); + } finally { + await handle.close(); + } + if ((stat.mode & 0o111) === 0) throw new Error("Staged CLI entry is not executable"); + // Parse-only: nothing from the registry runs until the operator activates it. The shebang's + // interpreter is used because process.execPath may be bun, which has no parse-only mode. + using smoke = execFileAsync(SERVER_UPDATE_CLI_INTERPRETER, ["--check", entry], { + timeoutMs: SERVER_UPDATE_SMOKE_TIMEOUT_MS, + signal, + }); + await smoke.result; + return entry; +} + +async function runInstall( + file: string, + args: string[], + cwd: string, + signal?: AbortSignal +): Promise { + using install = execFileAsync(file, args, { + cwd, + // The TLS variable disables certificate validation in every manager and the bun variable + // suppresses the lockfile; neither can be outranked by a flag. + env: { NODE_TLS_REJECT_UNAUTHORIZED: undefined, BUN_CONFIG_SKIP_SAVE_LOCKFILE: undefined }, + timeoutMs: SERVER_UPDATE_INSTALL_TIMEOUT_MS, + killTreeOnTermination: true, + signal, + }); + await install.result; +} + +export interface StageOptions { + install?: typeof runInstall; + request?: RegistryRequest; + signal?: AbortSignal; +} + +const markerSchema = z.object({ launcher: z.string() }); + +/** Only a stage this installation created may be pruned; a name collision is not ownership. */ +async function ownsStage(candidate: string, layout: InstallLayout): Promise { + try { + const raw: unknown = JSON.parse( + await fs.readFile(path.join(candidate, SERVER_UPDATE_STAGE_MARKER), "utf8") + ); + const marker = markerSchema.safeParse(raw); + return marker.success && marker.data.launcher === layout.launcher; + } catch { + return false; + } +} + +export async function stageUpdate( + layout: InstallLayout, + version: string, + options: StageOptions = {} +): Promise { + const { install = runInstall, request, signal } = options; + if (!isExactVersion(version)) throw new Error("Invalid update version"); + // Pruning must never remove the target of a launcher that was re-pointed behind this process. + if (resolveCliEntry(layout.launcher) !== layout.entry) + throw new Error("Server launcher changed since startup"); + const parent = path.dirname(layout.workdir); + const active = await fs.realpath(layout.workdir); + for (const entry of await fs.readdir(parent, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith(SERVER_UPDATE_STAGING_PREFIX)) continue; + const candidate = path.join(parent, entry.name); + if ((await fs.realpath(candidate)) !== active && (await ownsStage(candidate, layout))) + await fs.rm(candidate, { recursive: true }); + } + // A fresh, uniquely named directory: a foreign directory sharing the version's name can neither + // block the stage nor be replaced by it (rename would replace an empty one), and the marker + // lands first so a crash at any later point leaves a directory the next attempt prunes. + const dir = await fs.mkdtemp(path.join(parent, `${SERVER_UPDATE_STAGING_PREFIX}${version}.`)); + await fs.writeFile( + path.join(dir, SERVER_UPDATE_STAGE_MARKER), + JSON.stringify({ launcher: layout.launcher }) + ); + await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ private: true })); + // Package managers follow redirects, so the release itself is fetched and digest-checked here; + // the dependency tree the manager resolves is anchored to the registry afterwards. + const tarball = path.join(dir, `xum-${version}.tgz`); + await downloadArtifact( + await fetchArtifact(layout.registry, version, request, signal), + tarball, + request, + signal + ); + const command = installCommand(layout, tarball); + await install(command.file, command.args, dir, signal); + await verifyStagedDependencies(layout, dir, request, signal); + return verifyStagedPackage(dir, version, signal); +} diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index 15132ae3c6e..a8b2c236075 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -80,6 +80,8 @@ import { type AppTags, } from "@/node/services/di/tags"; import { ServiceContainer, StartupStepTimeoutError } from "./serviceContainer"; +import type { TurnCoordinator } from "@/node/services/turnCoordinator"; +import { registerInProcessWorkflowRun } from "@/node/services/workflows/workflowArchiveAdmission"; /** * Independent field → tag listing for every ORPC context field (the production @@ -171,6 +173,120 @@ describe("ServiceContainer", () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); + it("collects restart blockers from live sessions, including pre-stream work", () => { + services = new ServiceContainer(stores); + expect(services.collectRestartBlockers()).toEqual([]); + const session = services.workspaceService.getOrCreateSession("restart-test"); + const { coordinator } = session as unknown as { coordinator: TurnCoordinator }; + const turn = coordinator.prepare(); + expect(services.collectRestartBlockers()).toContainEqual({ kind: "pending-turns", count: 1 }); + coordinator.finishPreparation(turn); + session.queueMessage("queued for later"); + expect(services.collectRestartBlockers()).toEqual([{ kind: "queued-messages", count: 1 }]); + session.clearQueue(); + const retry = coordinator.beginRetry(); + expect(services.collectRestartBlockers()).toEqual([{ kind: "auto-retries", count: 1 }]); + coordinator.finishRetry(retry); + expect(services.collectRestartBlockers()).toEqual([]); + }); + + it("counts server-wide streams, terminal starts, and foreground or background processes", () => { + services = new ServiceContainer(stores); + const streams = services.streamManager as unknown as { workspaceStreams: Map }; + const terminals = services.terminalService as unknown as { + pendingSessionCreations: Map; + }; + const processes = services.backgroundProcessManager as unknown as { + processes: Map; + }; + const desktop = services.desktopSessionManager as unknown as { + sessions: Map; + startupPromises: Map>; + }; + const project = services.projectService as unknown as { activeGitInits: Set }; + let releaseWorkflow: (() => void) | undefined; + const workspace = services.workspaceService as unknown as { + preflightSendCounts: Map; + preflightExecCounts: Map; + initSettlementPromises: Map>; + initAbortControllers: Map; + removingWorkspaces: Set; + archivingWorkspaces: Set; + renamingWorkspaces: Set; + }; + try { + streams.workspaceStreams.set("streaming", {}); + workspace.initSettlementPromises.set("initializing", new Promise(() => undefined)); + workspace.initAbortControllers.set("initializing", new AbortController()); + workspace.initAbortControllers.set("provisioning", new AbortController()); + workspace.removingWorkspaces.add("removing"); + workspace.archivingWorkspaces.add("archiving"); + workspace.archivingWorkspaces.add("removing"); + workspace.renamingWorkspaces.add("renaming"); + releaseWorkflow = registerInProcessWorkflowRun("workflow-workspace"); + desktop.sessions.set("desktop-live", { isAlive: () => true }); + desktop.sessions.set("desktop-exited", { isAlive: () => false }); + desktop.startupPromises.set("desktop-starting", new Promise(() => undefined)); + project.activeGitInits.add("/tmp/new-project"); + terminals.pendingSessionCreations.set("terminal-starting", 2); + processes.processes.set("running", { status: "running", isForeground: false }); + processes.processes.set("foreground", { status: "running", isForeground: true }); + processes.processes.set("finished", { status: "exited", isForeground: false }); + workspace.preflightSendCounts.set("preflight", 1); + workspace.preflightExecCounts.set("executing", 1); + expect(services.collectRestartBlockers()).toEqual([ + { kind: "pending-turns", count: 1 }, + { kind: "workspace-inits", count: 2 }, + { kind: "workspace-lifecycle", count: 3 }, + { kind: "background-processes", count: 3 }, + { kind: "active-streams", count: 1 }, + { kind: "workflows", count: 1 }, + { kind: "projects", count: 1 }, + { kind: "terminals", count: 2 }, + { kind: "desktop-sessions", count: 2 }, + ]); + } finally { + streams.workspaceStreams.clear(); + terminals.pendingSessionCreations.clear(); + processes.processes.clear(); + workspace.preflightSendCounts.clear(); + workspace.preflightExecCounts.clear(); + workspace.initSettlementPromises.clear(); + workspace.initAbortControllers.clear(); + workspace.removingWorkspaces.clear(); + workspace.archivingWorkspaces.clear(); + workspace.renamingWorkspaces.clear(); + releaseWorkflow?.(); + desktop.sessions.clear(); + desktop.startupPromises.clear(); + project.activeGitInits.clear(); + } + expect(services.collectRestartBlockers()).toEqual([]); + }); + + it("refuses new sessions, commands, and terminals synchronously during disposal", async () => { + services = new ServiceContainer(stores); + expect(services.serverService.isShuttingDown()).toBe(false); + const disposal = services.dispose(); + expect(services.serverService.isShuttingDown()).toBe(true); + expect(() => services!.workspaceService.getOrCreateSession("cold-workspace")).toThrow( + "shutting down" + ); + expect(await services.workspaceService.executeBash("cold-workspace", "echo not-run")).toEqual({ + success: false, + error: "Server is shutting down", + }); + let terminalError: unknown; + try { + await services.terminalService.create({ workspaceId: "cold-workspace", cols: 80, rows: 24 }); + } catch (error) { + terminalError = error; + } + expect(terminalError).toBeInstanceOf(Error); + expect(String(terminalError)).toContain("shutting down"); + await disposal; + }); + it("attributes multi-project stream-end analytics to the primary project path", async () => { const primaryProjectPath = "/fake/project-a"; const secondaryProjectPath = "/fake/project-b"; diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index eb9a3b6e485..af9c858fe75 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -1,3 +1,6 @@ +import type { RestartBlocker } from "@/common/orpc/types"; +import { inFlightProcedureCount } from "@/node/orpc/inFlightProcedures"; +import { inProcessWorkflowWorkspaceCount } from "@/node/services/workflows/workflowArchiveAdmission"; import assert from "@/common/utils/assert"; import { log } from "@/node/services/log"; import type { Config, ConfigStores, WorkspaceSessionLocator } from "@/node/config"; @@ -202,7 +205,7 @@ export class ServiceContainer { public readonly memoryConsolidationService: CoreServices["memoryConsolidationService"]; public readonly refineService: RefineService; private readonly extensionMetadata: CoreServices["extensionMetadata"]; - private readonly backgroundProcessManager: CoreServices["backgroundProcessManager"]; + public readonly backgroundProcessManager: CoreServices["backgroundProcessManager"]; // Desktop-only services (`di/layers/desktop.ts`) public readonly projectService: ProjectService; public readonly muxGatewayOauthService: MuxGatewayOauthService; @@ -637,6 +640,32 @@ export class ServiceContainer { this.terminalService.setTerminalWindowManager(manager); } + /** Background process statuses refresh lazily, so refresh them before a blocker snapshot. */ + async refreshRestartBlockers(): Promise { + await this.backgroundProcessManager.list(); + } + + collectRestartBlockers(): RestartBlocker[] { + const blockers = this.workspaceService.collectRestartBlockers(); + const counts: Array<[RestartBlocker["kind"], number]> = [ + ["active-streams", this.streamManager.getActiveStreams().length], + ["workflows", inProcessWorkflowWorkspaceCount()], + ["projects", this.projectService.getMutationCount()], + ["requests", inFlightProcedureCount()], + ["terminals", this.terminalService.getOpenSessionCount()], + ["desktop-sessions", this.desktopSessionManager.getSessionCount()], + ["background-processes", this.backgroundProcessManager.getRunningProcessCount()], + ]; + for (const [kind, count] of counts) { + if (count > 0) { + const existing = blockers.find((blocker) => blocker.kind === kind); + if (existing) existing.count += count; + else blockers.push({ kind, count }); + } + } + return blockers; + } + /** * Dispose all services. Called on app quit to clean up resources. * Terminates all background processes to prevent orphans. Idempotent: @@ -670,7 +699,11 @@ export class ServiceContainer { // Chat recovery that housekeeping scheduled runs past its own promise and observes neither the // abort nor the join, so latch every session before the wait: nothing may start a stream inside // it, and nothing may dispatch through the provider/runtime services torn down below. + shutdownStep("serverService.beginShutdown", () => this.serverService.beginShutdown()); shutdownStep("workspaceService.beginShutdown", () => this.workspaceService.beginShutdown()); + shutdownStep("terminalService.beginShutdown", () => this.terminalService.beginShutdown()); + shutdownStep("projectService.beginShutdown", () => this.projectService.beginShutdown()); + await shutdownStep("updateService.beginShutdown", () => this.updateService.beginShutdown()); const housekeepingSettled = this.startupHousekeepingSettled; if (housekeepingSettled != null) { await shutdownStep("startupHousekeeping.join", async () => { diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts index 83f9a5c8c61..d33d846e68a 100644 --- a/src/node/services/terminalService.test.ts +++ b/src/node/services/terminalService.test.ts @@ -493,7 +493,9 @@ describe("TerminalService", () => { await service.create({ workspaceId: "ws-1", cols: 80, rows: 24 }); closeSessionMock.mockClear(); + expect(service.getOpenSessionCount()).toBe(1); service.closeAllSessions(); + expect(service.getOpenSessionCount()).toBe(0); expect(closeSessionMock).toHaveBeenCalled(); // PTY bulk close should NOT be used diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index 618bfee8763..3c4de192544 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -69,6 +69,7 @@ export class TerminalService { private readonly headlessOnDataDisposables = new Map void }>(); private readonly titleChangeDisposables = new Map void }>(); + private shuttingDown = false; // Per-session activity tracking for sidebar indicator. // Maps sessionId -> { workspaceId, isRunning (derived from terminal title) }. private readonly sessionActivity = new Map(); @@ -233,7 +234,12 @@ export class TerminalService { return proxyUriEnv; } + beginShutdown(): void { + this.shuttingDown = true; + } + async create(params: TerminalCreateParams): Promise { + if (this.shuttingDown) throw new Error("Server is shutting down"); // Reserve the startup synchronously: a creation that has passed its archived check but is // still awaiting metadata/secrets/PTY spawn is not yet in sessionActivity, so without this // reservation an archive's live-activity gate could pass and the pending creation would @@ -1281,6 +1287,13 @@ export class TerminalService { } } + getOpenSessionCount(): number { + return ( + this.sessionActivity.size + + Array.from(this.pendingSessionCreations.values()).reduce((sum, count) => sum + count, 0) + ); + } + /** * Whether any live terminal PTY sessions are tracked for a workspace. Model-facing * lifecycle paths consult this to refuse archiving instead of silently killing PTYs. diff --git a/src/node/services/updateService.test.ts b/src/node/services/updateService.test.ts index 07aa47cd41e..1845b0fd755 100644 --- a/src/node/services/updateService.test.ts +++ b/src/node/services/updateService.test.ts @@ -45,4 +45,112 @@ describe("UpdateService channel persistence", () => { expect(setUpdateChannel).toHaveBeenLastCalledWith("stable"); expect(service.getChannel()).toBe("stable"); }); + + it("leaves the runtime untouched when persistence fails", async () => { + const { config, setUpdateChannel } = createMockConfig("stable"); + setUpdateChannel.mockRejectedValueOnce(new Error("disk full")); + const service = new UpdateService(config); + const channels: UpdateChannel[] = []; + const internal = service as unknown as { + impl: { setChannel(channel: UpdateChannel): void; getChannel(): UpdateChannel }; + currentStatus: { type: string }; + }; + internal.impl = { + setChannel: (channel) => channels.push(channel), + getChannel: () => channels.at(-1) ?? "stable", + }; + internal.currentStatus = { type: "idle" }; + let failed = false; + try { + await service.setChannel("nightly"); + } catch { + failed = true; + } + expect(failed).toBe(true); + expect(channels).toEqual([]); + expect(service.getChannel()).toBe("stable"); + }); + + it("reverts the persisted channel when the runtime refuses the switch", async () => { + const { config, setUpdateChannel } = createMockConfig("stable"); + const service = new UpdateService(config); + const internal = service as unknown as { + impl: { setChannel(channel: UpdateChannel): void; getChannel(): UpdateChannel }; + currentStatus: { type: string }; + }; + internal.impl = { + setChannel: () => { + throw new Error("An update operation is in progress"); + }, + getChannel: () => "stable", + }; + internal.currentStatus = { type: "downloading" }; + let failed = false; + try { + await service.setChannel("nightly"); + } catch { + failed = true; + } + expect(failed).toBe(true); + expect(setUpdateChannel.mock.calls.map((call) => call[0])).toEqual(["nightly", "stable"]); + expect(service.getChannel()).toBe("stable"); + }); + + it("persists the channel while the updater reports an unsupported layout", async () => { + const { config, setUpdateChannel } = createMockConfig("stable"); + const service = new UpdateService(config); + const channels: UpdateChannel[] = []; + const internal = service as unknown as { + impl: { setChannel(channel: UpdateChannel): void; getChannel(): UpdateChannel }; + currentStatus: { type: string }; + }; + internal.impl = { + setChannel: (channel) => channels.push(channel), + getChannel: () => channels.at(-1) ?? "stable", + }; + internal.currentStatus = { type: "unsupported" }; + await service.setChannel("nightly"); + expect(setUpdateChannel).toHaveBeenLastCalledWith("nightly"); + expect(service.getChannel()).toBe("nightly"); + }); + + it("serializes concurrent changes so a rollback cannot land after a later switch", async () => { + const { config, setUpdateChannel, getUpdateChannel } = createMockConfig("stable"); + // Config writes complete in order but asynchronously, like the real FIFO editor. + let queue = Promise.resolve(); + setUpdateChannel.mockImplementation((channel) => { + queue = queue.then(() => new Promise((resolve) => setTimeout(resolve, 1))); + return queue.then(() => { + getUpdateChannel.mockReturnValue(channel); + }); + }); + const service = new UpdateService(config); + const channels: UpdateChannel[] = []; + const internal = service as unknown as { + impl: { setChannel(channel: UpdateChannel): void; getChannel(): UpdateChannel }; + currentStatus: { type: string }; + }; + let refusals = 1; + internal.impl = { + setChannel: (channel) => { + if (refusals-- > 0) throw new Error("An update operation is in progress"); + channels.push(channel); + }, + getChannel: () => channels.at(-1) ?? "stable", + }; + internal.currentStatus = { type: "idle" }; + const [first, second] = await Promise.allSettled([ + service.setChannel("nightly"), + service.setChannel("nightly"), + ]); + expect(first.status).toBe("rejected"); + expect(second.status).toBe("fulfilled"); + expect(setUpdateChannel.mock.calls.map((call) => call[0])).toEqual([ + "nightly", + "stable", + "nightly", + ]); + expect(getUpdateChannel()).toBe("nightly"); + expect(service.getChannel()).toBe("nightly"); + }); }); diff --git a/src/node/services/updateService.ts b/src/node/services/updateService.ts index f29bbacd883..dba38e2c859 100644 --- a/src/node/services/updateService.ts +++ b/src/node/services/updateService.ts @@ -3,13 +3,15 @@ import type { UpdateStatus } from "@/common/orpc/types"; import type { UpdateChannel } from "@/common/types/project"; import { parseDebugUpdater } from "@/common/utils/env"; import type { Config } from "@/node/config"; +import { ServerUpdater, type ServerUpdaterDeps } from "./serverUpdate/serverUpdater"; +import type { LayoutResult } from "./serverUpdate/installLayout"; -// Interface matching the implementation class in desktop/updater.ts -// We redefine it here to avoid importing the class directly which brings in electron-updater -interface DesktopUpdaterService { - checkForUpdates(options?: { source?: "auto" | "manual" }): void; +// Keep the Electron implementation out of CLI value imports. +interface UpdaterImpl { + checkForUpdates(options?: { source?: "auto" | "manual" }): void | Promise; downloadUpdate(): Promise; - installUpdate(): void; + installUpdate(): void | Promise; + beginShutdown?(): Promise; subscribe(callback: (status: UpdateStatus) => void): () => void; getStatus(): UpdateStatus; getChannel(): UpdateChannel; @@ -17,13 +19,17 @@ interface DesktopUpdaterService { } export class UpdateService { - private impl: DesktopUpdaterService | null = null; - private currentStatus: UpdateStatus = { type: "idle" }; + private impl: UpdaterImpl | null = null; + private currentStatus: UpdateStatus = { + type: "unsupported", + reason: "Server updater is not enabled for this process", + }; // Keep the user's stable/nightly preference loaded from config at startup so // the About dialog and updater initialization share the same persisted value. private currentChannel: UpdateChannel; private subscribers = new Set<(status: UpdateStatus) => void>(); private readonly ready: Promise; + private channelChange: Promise = Promise.resolve(); constructor(private readonly config: Config) { this.currentChannel = this.config.getUpdateChannel(); @@ -60,6 +66,16 @@ export class UpdateService { } } + async enableServerUpdater(layout: LayoutResult, deps: ServerUpdaterDeps): Promise { + await this.ready; + if (process.versions.electron) return; + this.impl = new ServerUpdater(layout, this.config.loadConfigOrDefault().updateChannel, deps); + this.impl.subscribe((status) => { + this.currentStatus = status; + this.notifySubscribers(); + }); + } + async check(options?: { source?: "auto" | "manual" }): Promise { await this.ready; if (this.impl) { @@ -82,7 +98,7 @@ export class UpdateService { log.debug("UpdateService: Error checking env:", err); } } - this.impl.checkForUpdates(options); + await this.impl.checkForUpdates(options); } else { log.debug("UpdateService: check() called but no implementation (CLI mode)"); } @@ -95,12 +111,16 @@ export class UpdateService { } } - install(): void { + async install(): Promise { if (this.impl) { - this.impl.installUpdate(); + await this.impl.installUpdate(); } } + async beginShutdown(): Promise { + await this.impl?.beginShutdown?.(); + } + getChannel(): UpdateChannel { if (this.impl) { return this.impl.getChannel(); @@ -110,14 +130,26 @@ export class UpdateService { } async setChannel(channel: UpdateChannel): Promise { + // Persist, switch, and roll back run as one transaction: a second change interleaving with + // them could leave the runtime on one channel and the config on another. + const change = this.channelChange.then(() => this.changeChannel(channel)); + this.channelChange = change.catch(() => undefined); + await change; + } + + private async changeChannel(channel: UpdateChannel): Promise { await this.ready; - // Apply runtime switch first — it throws if the updater is in a blocked - // state (checking/downloading/downloaded). Only persist after success so - // config and runtime stay in sync. - if (this.impl) { - this.impl.setChannel(channel); - } + // The runtime switch discards a staged update, so persist first: a failed write then costs + // nothing, and a runtime refusal (operation in progress) reverts the write so the two never + // disagree. + const previous = this.impl?.getChannel() ?? this.currentChannel; await this.config.setUpdateChannel(channel); + try { + this.impl?.setChannel(channel); + } catch (error) { + await this.config.setUpdateChannel(previous); + throw error; + } this.currentChannel = channel; } diff --git a/src/node/services/workflows/workflowArchiveAdmission.ts b/src/node/services/workflows/workflowArchiveAdmission.ts index 902d6a593d9..65dcb8755da 100644 --- a/src/node/services/workflows/workflowArchiveAdmission.ts +++ b/src/node/services/workflows/workflowArchiveAdmission.ts @@ -69,6 +69,13 @@ export function registerInProcessWorkflowRun(workspaceId: string): () => void { return incrementInProcessWorkflowWork(workspaceId); } +/** Workspaces with a workflow admission or in-process runner, across the whole process. */ +export function inProcessWorkflowWorkspaceCount(): number { + let count = 0; + for (const value of inProcessWorkflowWorkByWorkspace.values()) if (value > 0) count++; + return count; +} + /** Whether any workflow admission or in-process runner exists for this workspace. */ export function hasInProcessWorkflowWork(workspaceId: string): boolean { return (inProcessWorkflowWorkByWorkspace.get(workspaceId) ?? 0) > 0; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 819ee590112..443a2308d14 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1,3 +1,4 @@ +import type { RestartBlocker } from "@/common/orpc/types"; import { DesktopInputCoordinator, settleArchivedSharedDesktopTask, @@ -1808,6 +1809,7 @@ const DELEGATED_TURN_CONTINUATION_OPTIONS_SCHEMA = SendMessageOptionsSchema.pick // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class WorkspaceService extends EventEmitter implements WorkspaceHost { private readonly sessions = new Map(); + private shuttingDown = false; private readonly providerConfigChangedListener = (): void => { const liveSessions = new Map([ ...this.sessions.entries(), @@ -3960,6 +3962,45 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { ); } + collectRestartBlockers(): RestartBlocker[] { + const sessions = new Map([...this.sessions, ...this.transientStartupRecoverySessions]); + const pendingTurns = new Set(this.preflightSendCounts.keys()); + let queuedMessages = 0; + let autoRetries = 0; + for (const [workspaceId, session] of sessions) { + if (session.hasActiveOrPendingTurnWork()) pendingTurns.add(workspaceId); + if (session.hasQueuedMessages()) queuedMessages++; + if (session.hasPendingAutoRetry()) autoRetries++; + } + const blockers: RestartBlocker[] = [ + { kind: "pending-turns", count: pendingTurns.size }, + { + kind: "workspace-inits", + // Controllers exist from the start of provisioning; settlements from init start onward. + count: new Set([...this.initAbortControllers.keys(), ...this.initSettlementPromises.keys()]) + .size, + }, + { + kind: "workspace-lifecycle", + count: new Set([ + ...this.renamingWorkspaces, + ...this.removingWorkspaces, + ...this.archivingWorkspaces, + ...this.contextMutationWorkspaces, + ...this.preflightForkCounts.keys(), + ...this.preflightStagingCounts.keys(), + ]).size, + }, + { kind: "queued-messages", count: queuedMessages }, + { kind: "auto-retries", count: autoRetries }, + { + kind: "background-processes", + count: Array.from(this.preflightExecCounts.values()).reduce((sum, count) => sum + count, 0), + }, + ]; + return blockers.filter((blocker) => blocker.count > 0); + } + /** * Shutdown: stop startup chat recovery before the services it dispatches through go away. * Transient recovery sessions are disposed outright. Sessions that outlived that sweep (promoted @@ -3967,6 +4008,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { * on) may own a live stream whose partial the next startup needs, so they only stop dispatching. */ beginShutdown(): void { + this.shuttingDown = true; for (const [workspaceId, session] of this.transientStartupRecoverySessions) { this.transientStartupRecoverySessions.delete(workspaceId); session.dispose(); @@ -4025,6 +4067,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } private createSession(workspaceId: string): AgentSession { + if (this.shuttingDown) throw new Error("Server is shutting down"); return new AgentSession({ workspaceId, config: this.config, @@ -5529,6 +5572,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } private async removeUnlocked(workspaceId: string, force = false): Promise> { + if (this.shuttingDown) return Err("Server is shutting down"); // Idempotent: if already removing, return success to prevent race conditions if (this.removingWorkspaces.has(workspaceId)) { return Ok(undefined); @@ -6904,6 +6948,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { async rename(workspaceId: string, newName: string): Promise> { try { + if (this.shuttingDown) return Err("Server is shutting down"); if (this.aiService.isStreaming(workspaceId)) { return Err( "Cannot rename workspace while AI stream is active. Please wait for the stream to complete." @@ -8402,6 +8447,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { acknowledgedUntrackedPaths?: string[], options?: ArchiveWorkspaceOptions ): Promise> { + if (this.shuttingDown) return Err("Server is shutting down"); this.archivingWorkspaces.add(workspaceId); let admissionHold: Disposable | undefined; @@ -14252,6 +14298,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { command?: string, args?: string[] ): Promise> { + if (this.shuttingDown) return Err("Server is shutting down"); // Block bash execution while workspace is being removed to prevent races with directory deletion. // A common case: subagent calls agent_report → frontend's GitStatusStore triggers a git status // refresh → executeBash arrives while remove() is deleting the directory → spawn fails with ENOENT. diff --git a/src/node/utils/disposableExec.test.ts b/src/node/utils/disposableExec.test.ts index a0a1d5a5ce8..c9650d64b7c 100644 --- a/src/node/utils/disposableExec.test.ts +++ b/src/node/utils/disposableExec.test.ts @@ -166,6 +166,16 @@ describe("disposableExec", () => { expect(childProc.killed).toBe(false); }); + test("cwd option runs the command in the requested directory", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "disposable-exec-cwd-")); + try { + using command = execFileAsync("node", ["-p", "process.cwd()"], { cwd }); + expect((await command.result).stdout.trim()).toBe(await fs.realpath(cwd)); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); + test("stdout and stderr are captured correctly", async () => { using proc = execAsync("echo 'stdout message' && echo 'stderr message' >&2"); const childProc = (proc as any).child; diff --git a/src/node/utils/disposableExec.ts b/src/node/utils/disposableExec.ts index d396527d4eb..f25de8da25e 100644 --- a/src/node/utils/disposableExec.ts +++ b/src/node/utils/disposableExec.ts @@ -269,6 +269,7 @@ export function execAsync(command: string, options?: ExecAsyncOptions): Disposab * Options for execFileAsync. */ export interface ExecFileAsyncOptions { + cwd?: string; /** Extra environment variables for the child process. */ env?: Record; /** Optional callback for each stderr data chunk from the process. */ @@ -322,6 +323,7 @@ export function execFileAsync( const killsProcessTree = options?.maxOutputBytes !== undefined || options?.killTreeOnTermination === true; const child = spawn(file, args, { + cwd: options?.cwd, stdio: ["ignore", "pipe", "pipe"], env: options?.env ? { ...process.env, ...options.env } : undefined, // Unix tree termination needs a separate process group, but detaching also hides terminal diff --git a/tests/ipc/update.test.ts b/tests/ipc/update.test.ts new file mode 100644 index 00000000000..f61f532d7af --- /dev/null +++ b/tests/ipc/update.test.ts @@ -0,0 +1,25 @@ +import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; +import { resolveOrpcClient } from "./helpers"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("Server update IPC", () => { + test("reports unsupported instead of offering an unsafe restart in the harness", async () => { + const env = await createTestEnvironment(); + const controller = new AbortController(); + try { + const client = resolveOrpcClient(env); + const statuses = await client.update.onStatus(undefined, { signal: controller.signal }); + const first = await statuses.next(); + expect(first.value).toMatchObject({ type: "unsupported", reason: expect.any(String) }); + await client.update.check({ source: "manual" }); + await client.update.download(); + await client.update.install(); + // An unsupported install must not have started the restart path: the server still answers. + expect(await client.update.getChannel()).toBe("stable"); + } finally { + controller.abort(); + await cleanupTestEnvironment(env); + } + }, 30000); +}); diff --git a/vscode/Makefile b/vscode/Makefile index 24fc429ec4b..8248dd94e87 100644 --- a/vscode/Makefile +++ b/vscode/Makefile @@ -2,7 +2,7 @@ # ============================== # Isolated build system for the mux VS Code/Cursor extension -.PHONY: all build install clean test test-integration test-orpc help +.PHONY: all build install clean test test-integration test-orpc help version # Default target all: build @@ -21,8 +21,14 @@ node_modules/.installed: package.json @bun install @touch node_modules/.installed +# The webview imports the root's generated version module. Phony like the root's `version` +# target, so every build path (including a direct `make -C vscode`) regenerates it instead of +# trusting a stale file left by an earlier checkout. +version: + @$(MAKE) -C .. src/version.ts + ## Build extension package -build: node_modules/.installed ## Build VS Code extension (.vsix) +build: node_modules/.installed version ## Build VS Code extension (.vsix) @echo "Building VS Code extension with esbuild..." @rm -rf out mux-0.1.0.vsix @bun run compile