Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/downloads/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ set -eu
# No-op on POSIX sh/dash, active on bash/zsh.
(set -o pipefail) 2>/dev/null && set -o pipefail || true

VERSION="${UP_VERSION:-v0.1.0-beta.1}"
VERSION="${UP_VERSION:-v0.1.0-beta.2}"
INSTALL_DIR="${UP_INSTALL_DIR:-${HOME:?HOME is required}/.local/bin}"
DOWNLOAD_ROOT="${UP_DOWNLOAD_ROOT:-https://cdn.upcli.dev/releases/${VERSION}}"

Expand Down
1 change: 1 addition & 0 deletions apps/downloads/public/releases/v0.1.0-beta.2/checksums.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

446 changes: 446 additions & 0 deletions apps/downloads/public/releases/v0.1.0-beta.2/up.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@types/node": "^25.9.1",
"@vercel/frameworks": "^3.26.1",
"@vercel/fs-detectors": "6.7.4",
"@vercel/sandbox": "^2.0.1",
"@vercel/sandbox": "^2.1.1",
"citty": "^0.2.2",
"env-paths": "^4.0.0",
"esbuild": "^0.27.7",
Expand Down
42 changes: 38 additions & 4 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { type EnvFile, findDefaultEnvFile, readEnvFile } from "./env.js";
import { type PromptDraft, resolveLaunchProfile } from "./launch-profile.js";
import { SandboxLifecycle } from "./lifecycle.js";
import { waitForPort, waitForSupervisor } from "./ready.js";
import { isRetryableTransport } from "./retry.js";
import type { DevSandbox } from "./sandbox.js";
import { DEFAULT_TIMEOUT_MS, VercelProvider } from "./sandbox.js";
import { installEtaFromDeps, startEtaFor, writeStatus } from "./status.js";
Expand Down Expand Up @@ -84,7 +85,7 @@ async function remoteTextEquals(
const { exitCode } = await sandbox.exec(
"bash",
["-c", 'test -f "$1" && test "$(cat "$1")" = "$2"', "dev", absPath, expected],
{ signal },
{ signal, retryTransport: true },
);
return exitCode === 0;
}
Expand Down Expand Up @@ -526,7 +527,10 @@ async function runDev(
);
await resources.run((signal) =>
sandbox
.exec("bash", ["-c", "pkill -f dev-supervisor.cjs >/dev/null 2>&1 || true"], { signal })
.exec("bash", ["-c", "pkill -f dev-supervisor.cjs >/dev/null 2>&1 || true"], {
signal,
retryTransport: true,
})
.catch(() => ({ exitCode: 0 })),
);
const supervisorProc = await resources.run((signal) =>
Expand Down Expand Up @@ -730,6 +734,7 @@ async function runDev(
env: { PORT: String(devPort) },
onLog: (chunk) => recent.push(chunk),
signal,
retryTransport: true,
}),
);
if (exitCode !== 0) {
Expand Down Expand Up @@ -993,7 +998,16 @@ async function runDev(
);
}
} catch (stopErr) {
await ui.warn(`Could not save snapshot after startup failure: ${formatError(stopErr)}`);
// A transport-level snapshot failure usually shares the root cause reported
// below, so keep it a dim aside rather than a second alarming error. A
// genuinely different failure still surfaces loudly.
if (isRetryableTransport(stopErr)) {
await ui.info(
pc.dim("Could not save a snapshot of the failed sandbox; run `up .` to retry."),
);
} else {
await ui.warn(`Could not save snapshot after startup failure: ${formatError(stopErr)}`);
}
}
return fail(err);
}
Expand Down Expand Up @@ -1063,7 +1077,27 @@ function ring(max: number) {
}

function formatError(err: unknown): string {
return sanitizeTerminalText(err instanceof Error ? err.message : String(err));
const base = sanitizeTerminalText(err instanceof Error ? err.message : String(err));
// The Sandbox SDK's APIError carries the HTTP response and raw body, which the
// message alone hides. Surface the status, an unexpected content-type, and a
// body snippet so transport failures (proxies, gateways, rate limits) are
// diagnosable instead of opaque ("Expected a stream of command data").
if (err && typeof err === "object") {
const e = err as {
response?: { status?: number; headers?: { get?: (name: string) => string | null } };
text?: unknown;
};
const detail: string[] = [];
if (typeof e.response?.status === "number") detail.push(`HTTP ${e.response.status}`);
const contentType = e.response?.headers?.get?.("content-type");
if (contentType && !/json/i.test(contentType)) detail.push(`content-type ${contentType}`);
if (typeof e.text === "string" && e.text.trim()) {
const snippet = e.text.trim().replace(/\s+/g, " ").slice(0, 160);
if (!base.includes(snippet)) detail.push(`body: ${snippet}`);
}
if (detail.length > 0) return `${base} (${sanitizeTerminalText(detail.join("; "))})`;
}
return base;
}

function formatPaths(paths: string[]): string {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ready.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function waitForPort(
): Promise<boolean> {
const iterations = Math.ceil(timeoutMs / 100);
const script = `for i in $(seq 1 ${iterations}); do (echo > /dev/tcp/127.0.0.1/${port}) 2>/dev/null && exit 0; sleep 0.1; done; exit 1`;
const { exitCode } = await sandbox.exec("bash", ["-c", script], { signal });
const { exitCode } = await sandbox.exec("bash", ["-c", script], { signal, retryTransport: true });
return exitCode === 0;
}

Expand Down
28 changes: 27 additions & 1 deletion packages/cli/src/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ export interface RetryOptions {
baseMs?: number;
/** Called before each retry (not before the first attempt). */
onRetry?: (err: unknown, attempt: number) => void;
/**
* Gate retries on the error: an error that does not match is rethrown
* immediately without further attempts. Defaults to retrying any error.
*/
shouldRetry?: (err: unknown) => boolean;
}

/**
Expand All @@ -22,10 +27,31 @@ export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions = {}
return await fn();
} catch (err) {
lastErr = err;
if (attempt >= attempts) break;
if (attempt >= attempts || (opts.shouldRetry && !opts.shouldRetry(err))) break;
opts.onRetry?.(err, attempt);
await new Promise((resolve) => setTimeout(resolve, baseMs * 2 ** (attempt - 1)));
}
}
throw lastErr;
}

/**
* True for transport/infrastructure failures that are safe to retry on
* idempotent operations: dropped command streams, request timeouts, rate limits,
* 5xx, and the 2xx-but-malformed responses the Sandbox SDK reports when a proxy
* or gateway mangles a streaming/JSON response. Excludes aborts and ordinary
* application errors so a genuine failure is never masked by retries.
*/
export function isRetryableTransport(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
// SDK StreamError: the command stream ended before data or finish arrived.
if ((err as { name?: string }).name === "StreamError") return true;
const status = (err as { response?: { status?: number } }).response?.status;
if (typeof status === "number" && (status === 408 || status === 429 || status >= 500)) {
return true;
}
const message = err instanceof Error ? err.message : "";
return /Expected a stream of command data|No response body|Can't (parse JSON|read response text)/.test(
message,
);
}
52 changes: 37 additions & 15 deletions packages/cli/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Writable } from "node:stream";
import { setTimeout as sleep } from "node:timers/promises";
import { Sandbox } from "@vercel/sandbox";
import type { Credentials } from "./auth.js";
import { withRetry } from "./retry.js";
import { isRetryableTransport, withRetry } from "./retry.js";

export interface DevFile {
/** Path relative to the project root inside the sandbox, or absolute. */
Expand All @@ -18,6 +18,12 @@ export interface ExecOptions {
/** Called with each chunk of combined stdout/stderr output. */
onLog?: (chunk: string) => void;
signal?: AbortSignal;
/**
* Retry transport-level failures (mangled/dropped responses from a proxy or
* gateway). Only set this for commands that are safe to run more than once,
* since a retry re-executes the command. Defaults to no retry.
*/
retryTransport?: boolean;
}

export interface ExecResult {
Expand Down Expand Up @@ -93,16 +99,23 @@ class VercelDevSandbox implements DevSandbox {

async exec(cmd: string, args: string[], opts: ExecOptions = {}): Promise<ExecResult> {
const stream = opts.onLog ? writableForLog(opts.onLog) : undefined;
const finished = await this.sandbox.runCommand({
cmd,
args,
cwd: opts.cwd,
env: opts.env,
sudo: opts.sudo,
signal: opts.signal,
stdout: stream,
stderr: stream,
});
const run = () =>
this.sandbox.runCommand({
cmd,
args,
cwd: opts.cwd,
env: opts.env,
sudo: opts.sudo,
signal: opts.signal,
stdout: stream,
stderr: stream,
});
// Retrying re-executes the command, so it is opt-in (only callers that pass
// an idempotent command set retryTransport). A real non-zero exit is returned
// as a result, not thrown, so it is never treated as a transport failure.
const finished = opts.retryTransport
? await withRetry(run, { shouldRetry: isRetryableTransport })
: await run();
return { exitCode: finished.exitCode };
}

Expand Down Expand Up @@ -143,16 +156,25 @@ class VercelDevSandbox implements DevSandbox {
const wasStopped = this.sandbox.status === "stopped";
const isNewSnapshot = (snapshotId: string | undefined): snapshotId is string =>
Boolean(snapshotId && (!beforeSnapshotId || snapshotId !== beforeSnapshotId));
const stopped = await this.sandbox.stop();
const snapshotId = stopped.snapshot?.id;
// A transport hiccup can mangle the stop response even though the stop was
// dispatched. Rather than blindly re-issuing stop (which risks a confusing
// double-stop), swallow a transport error and let the polling below confirm
// the result via Sandbox.get; any other error is a real failure and rethrows.
let stopped: Awaited<ReturnType<Sandbox["stop"]>> | undefined;
try {
stopped = await this.sandbox.stop();
} catch (err) {
if (!isRetryableTransport(err)) throw err;
}
const snapshotId = stopped?.snapshot?.id;
if (snapshotId && (wasStopped || isNewSnapshot(snapshotId))) {
return { status: stopped.status, snapshotId };
return { status: stopped?.status, snapshotId };
}

// A stopped session can expose its snapshot pointer shortly after stop resolves.
const deadline = Date.now() + 60_000;
let last: { status?: string; snapshotId?: string } = {
status: stopped.status,
status: stopped?.status,
snapshotId: this.sandbox.currentSnapshotId,
};
while (Date.now() < deadline) {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ export async function removeFiles(sandbox: DevSandbox, relPaths: string[]): Prom
const targets = relPaths.map((rel) =>
path.posix.join(REMOTE_ROOT, validateRelativeSyncPath(rel)),
);
await sandbox.exec("rm", ["-f", ...targets]);
await sandbox.exec("rm", ["-f", ...targets], { retryTransport: true });
}

export interface ReconcileResult {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/toolchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ async function runProvisionScript(
while (recent.length > 40) recent.shift();
},
signal,
retryTransport: true,
}),
);
if (exitCode !== 0) {
Expand Down
57 changes: 56 additions & 1 deletion packages/cli/test/retry.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import { withRetry } from "../src/retry.js";
import { isRetryableTransport, withRetry } from "../src/retry.js";

describe("withRetry", () => {
test("returns immediately when the first attempt succeeds", async () => {
Expand Down Expand Up @@ -28,4 +28,59 @@ describe("withRetry", () => {
await expect(withRetry(fn, { attempts: 3, baseMs: 1 })).rejects.toThrow("still down");
expect(fn).toHaveBeenCalledTimes(3);
});

test("stops immediately when shouldRetry returns false", async () => {
const fn = vi.fn(async () => {
throw new Error("fatal");
});
await expect(
withRetry(fn, { attempts: 3, baseMs: 1, shouldRetry: () => false }),
).rejects.toThrow("fatal");
expect(fn).toHaveBeenCalledTimes(1);
});

test("keeps retrying while shouldRetry returns true", async () => {
let n = 0;
const fn = vi.fn(async () => {
n++;
if (n < 2) throw new Error("retryable");
return "ok";
});
expect(await withRetry(fn, { attempts: 3, baseMs: 1, shouldRetry: () => true })).toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
});
});

describe("isRetryableTransport", () => {
test("matches the SDK StreamError (dropped command stream)", () => {
const err = Object.assign(new Error("stream ended early"), { name: "StreamError" });
expect(isRetryableTransport(err)).toBe(true);
});

test("matches request-timeout, rate-limit, and 5xx status codes", () => {
for (const status of [408, 429, 500, 502, 503]) {
expect(isRetryableTransport({ response: { status } })).toBe(true);
}
});

test("matches the SDK's malformed-response messages", () => {
for (const message of [
"Expected a stream of command data",
"No response body",
"Can't parse JSON: SyntaxError: Unexpected non-whitespace character after JSON at position 1",
"Can't read response text: TypeError: terminated",
]) {
expect(isRetryableTransport(new Error(message))).toBe(true);
}
});

test("does not match aborts, client errors, or ordinary failures", () => {
expect(isRetryableTransport(new Error("The operation was aborted"))).toBe(false);
expect(isRetryableTransport({ response: { status: 400 } })).toBe(false);
expect(isRetryableTransport({ response: { status: 404 } })).toBe(false);
expect(isRetryableTransport(new Error("`npm install` exited with 1"))).toBe(false);
expect(isRetryableTransport(undefined)).toBe(false);
expect(isRetryableTransport(null)).toBe(false);
expect(isRetryableTransport("string error")).toBe(false);
});
});
18 changes: 9 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ minimumReleaseAge: 2880
minimumReleaseAgeExclude:
- "@vercel/fs-detectors"
- "@vercel/build-utils"
- "@vercel/sandbox"
# Required floor (^7.27.1) of @vercel/sandbox; undici is Node's HTTP client.
- "undici"
Loading