From c3570dfedf407b7541bcbcaf05ce00cbd26673ff Mon Sep 17 00:00:00 2001 From: Luke Phillips-Sheard Date: Wed, 26 Aug 2026 23:23:42 +0100 Subject: [PATCH 1/2] [interactive]: Ensure that command server is healthy before connecting --- packages/sandbox/package.json | 5 +- .../interactive-shell/interactive-shell.ts | 54 +++++++++++++++++-- pnpm-lock.yaml | 12 ++--- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index f0fc7d9f..f7e519fe 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -4,9 +4,9 @@ "version": "4.1.0", "scripts": { "clean": "rm -rf node_modules dist", - "sandbox": "ts-node ./src/sandbox.ts", + "sandbox": "tsx ./src/sandbox.ts", "build": "tsdown", - "vercel-build": "ts-node ./scripts/create-preview-deployment-tgz.ts", + "vercel-build": "tsx ./scripts/create-preview-deployment-tgz.ts", "test": "vitest", "example-whoami": "ts-node ./src/example-whoami.ts", "example-next": "ts-node ./src/example-next.ts", @@ -61,6 +61,7 @@ "open": "^10.2.0", "ora": "^8.2.0", "tsdown": "catalog:", + "tsx": "4.20.3", "vitest": "catalog:", "xdg-app-paths": "5.1.0" } diff --git a/packages/sandbox/src/interactive-shell/interactive-shell.ts b/packages/sandbox/src/interactive-shell/interactive-shell.ts index 2801ab30..97166776 100644 --- a/packages/sandbox/src/interactive-shell/interactive-shell.ts +++ b/packages/sandbox/src/interactive-shell/interactive-shell.ts @@ -1,9 +1,14 @@ import { Sandbox } from "@vercel/sandbox"; import createDebugger from "debug"; +import retry from "async-retry"; import { WebSocket } from "ws"; import { printCommand } from "../util/print-command"; import ora from "ora"; -import { acquireRelease, createAbortController, defer } from "../util/disposables"; +import { + acquireRelease, + createAbortController, + defer, +} from "../util/disposables"; import chalk from "chalk"; import { extendSandboxTimeoutPeriodically } from "./extend-sandbox-timeout"; @@ -68,6 +73,7 @@ export async function startInteractiveShell(options: { (s) => s.stop(), ); + debug("Opening interactive session..."); progress.text = "Opening interactive session..."; const { url, token } = await options.sandbox.openInteractive(); @@ -76,8 +82,47 @@ export async function startInteractiveShell(options: { ? ["sudo", command, ...args] : [command, ...args]; + debug("Connecting..."); progress.text = "Connecting..."; - const client = new WebSocket(`${url}?token=${encodeURIComponent(token)}`); + + let client: WebSocket; + try { + // Wait for the websocket server to be ready + // to accept traffic. This is important for + // cross region connections because the sevrer + // will have been instructed to start, but may not + // be fully ready to accept connections yet. + const { hostname } = new URL(url); + await retry( + async (_, attempt) => { + debug("Validating server health... (attempt %d)", attempt); + const response = await fetch(`https://${hostname}/health`); + if (!response.ok) { + throw new Error(`Server health check failed: ${response.statusText}`); + } + }, + { + onRetry: (error: unknown, attempt: number) => { + debug( + "Server health check failed: %s (attempt %d)", + (error as Error).message, + attempt, + ); + }, + retries: 5, + minTimeout: 100, + randomize: true, + }, + ); + + client = new WebSocket(`${url}?token=${encodeURIComponent(token)}`); + } catch (e) { + console.error( + chalk.dim(`\n╰▶ connection to ▲ ${options.sandbox.name} failed.`), + ); + process.exitCode = 1; + return; + } using _client = defer(() => { try { client.close(); @@ -91,6 +136,7 @@ export async function startInteractiveShell(options: { client.once("error", (err) => reject(err)); }); debug("connected to %s", url); + progress.text = "Connected successfully."; client.send( JSON.stringify({ @@ -165,7 +211,9 @@ export async function startInteractiveShell(options: { process.removeListener("SIGWINCH", onResize); process.stdin.removeListener("data", onStdin); - console.error(chalk.dim(`\n╰▶ connection to ▲ ${options.sandbox.name} closed.`)); + console.error( + chalk.dim(`\n╰▶ connection to ▲ ${options.sandbox.name} closed.`), + ); } function toEnvArray(env: Record): string[] { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db9213ef..700feba2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,6 +358,9 @@ importers: tsdown: specifier: 'catalog:' version: 0.16.6(typescript@5.8.3) + tsx: + specifier: 4.20.3 + version: 4.20.3 vitest: specifier: 'catalog:' version: 3.2.1(@types/debug@4.1.12)(@types/node@22.15.12)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.9.0) @@ -3870,9 +3873,6 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-tsconfig@4.10.1: - resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} - get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} @@ -9893,10 +9893,6 @@ snapshots: get-stream@6.0.1: {} - get-tsconfig@4.10.1: - dependencies: - resolve-pkg-maps: 1.0.0 - get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -12182,7 +12178,7 @@ snapshots: tsx@4.20.3: dependencies: esbuild: 0.25.9 - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.0 optionalDependencies: fsevents: 2.3.3 From 533b0afd930abe4835951be52f0f70645dfd2887 Mon Sep 17 00:00:00 2001 From: Luke Phillips-Sheard Date: Thu, 27 Aug 2026 08:48:14 +0100 Subject: [PATCH 2/2] changeset --- .changeset/wide-ants-buy.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wide-ants-buy.md diff --git a/.changeset/wide-ants-buy.md b/.changeset/wide-ants-buy.md new file mode 100644 index 00000000..4ec7d96a --- /dev/null +++ b/.changeset/wide-ants-buy.md @@ -0,0 +1,5 @@ +--- +"sandbox": patch +--- + +Ensure that the interactive server is available before creating websocket connection requests.