diff --git a/CHANGELOG.md b/CHANGELOG.md
index 073dc1b90a..b6704c64e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,21 @@ file is the human-readable summary of what changed and why.
Windows versions below Windows 11 a compatibility warning is shown but
creation is never blocked. The sandbox image is downloaded on first use.
+- **Sandbox image downloads survive unstable connections** — the multi-hundred-MB
+ sandbox image download now resumes from where it stopped (HTTP range requests)
+ when the connection is cut, retrying until it stops making progress instead of
+ failing the whole create on the first drop. An interrupted download also
+ resumes on the next create attempt rather than starting over, and stale
+ partial download files are cleaned up instead of accumulating in the image
+ cache.
+
+- **Sandbox creation shows progress and can be cancelled** — while the image
+ downloads, the Add sandbox dialog shows a progress bar with the downloaded
+ and total size in MB, and the Cancel button stays available: cancelling
+ aborts the download without registering the sandbox (already-downloaded
+ bytes are kept, so the next attempt resumes). The dialog body also gets
+ proper horizontal margins.
+
- **WSL setup no longer depends on reaching Microsoft's servers** — on
Windows, **Install WSL** now downloads a pinned install bundle from the
CoDev storage host (the one host whitelist-only corporate proxies already
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index c1b956ab13..4e7e202662 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -392,6 +392,7 @@ export const dict = {
"sandbox.add.choose": "Choose...",
"sandbox.add.create": "Create",
"sandbox.add.downloadHint": "The first sandbox downloads its image; on a slow connection this can take a while.",
+ "sandbox.add.downloadProgress": "Downloading image… {{done}}/{{total}} MB",
"wsl.onboarding.step.distro": "Choose distro",
"wsl.onboarding.step.opencode": "CoDev Code",
"wsl.onboarding.checkingRuntime": "Checking WSL...",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 00cd9b8261..0d007692da 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -383,6 +383,7 @@ export const dict = {
"sandbox.add.choose": "Elegir...",
"sandbox.add.create": "Crear",
"sandbox.add.downloadHint": "El primer sandbox descarga su imagen; con una conexión lenta puede tardar un poco.",
+ "sandbox.add.downloadProgress": "Descargando imagen… {{done}}/{{total}} MB",
"wsl.onboarding.step.distro": "Elegir distribución",
"wsl.onboarding.step.opencode": "CoDev Code",
"wsl.onboarding.checkingRuntime": "Comprobando WSL...",
diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx
index a7b639407f..e829236cf3 100644
--- a/packages/app/src/sandbox/dialog-add.tsx
+++ b/packages/app/src/sandbox/dialog-add.tsx
@@ -43,12 +43,22 @@ export function DialogAddSandbox() {
})
}
+ // The create job's image-download progress, pushed by the main process
+ // while this dialog's add is in flight.
+ const progress = () => {
+ if (!store.adding) return undefined
+ const job = sandboxes.state.data?.job
+ return job?.kind === "create" ? job.progress : undefined
+ }
+
const create = async () => {
if (!canCreate() || store.adding) return
setStore("adding", true)
try {
- await api.add(store.name, store.projectDir)
- dialog.close()
+ // add resolves undefined when the download was cancelled: keep the
+ // dialog open with the form intact so the user can try again.
+ const created = await api.add(store.name, store.projectDir)
+ if (created) dialog.close()
} catch (error) {
showToast({
variant: "error",
@@ -60,13 +70,21 @@ export function DialogAddSandbox() {
}
}
+ const cancel = () => {
+ if (!store.adding) {
+ dialog.close()
+ return
+ }
+ void api.cancel(store.name)
+ }
+
return (
}
@@ -96,11 +114,31 @@ export function DialogAddSandbox() {
{language.t("sandbox.add.choose")}
- {language.t("sandbox.add.downloadHint")}
+ {language.t("sandbox.add.downloadHint")}}
+ >
+ {(current) => (
+
+
+
+ {language.t("sandbox.add.downloadProgress", {
+ done: Math.round(current().bytes / 1024 / 1024),
+ total: Math.round(current().totalBytes / 1024 / 1024),
+ })}
+
+
+ )}
+
- dialog.close()}>
+
{language.t("common.cancel")}
diff --git a/packages/app/src/sandbox/types.ts b/packages/app/src/sandbox/types.ts
index 60f29ff789..f6e95575a5 100644
--- a/packages/app/src/sandbox/types.ts
+++ b/packages/app/src/sandbox/types.ts
@@ -23,10 +23,13 @@ export type SandboxItem = {
runtime: SandboxRuntime
}
+export type SandboxProgress = { bytes: number; totalBytes: number }
+
export type SandboxJob = {
kind: "create" | "destroy" | "reset"
id: string
startedAt: number
+ progress?: SandboxProgress
}
export type SandboxesState = {
@@ -44,7 +47,10 @@ export type SandboxesPlatform = {
getState(): Promise
subscribe(cb: (event: SandboxesEvent) => void): () => void
availability(): Promise
- add(name: string, projectDir: string): Promise
+ // Resolves undefined when the create was cancelled mid-download.
+ add(name: string, projectDir: string): Promise
+ // Aborts the in-flight create/reset job for this sandbox name, if any.
+ cancel(name: string): Promise
remove(id: string): Promise
reset(id: string): Promise
start(id: string): Promise
diff --git a/packages/codev-sandbox/src/bwrap/provider.ts b/packages/codev-sandbox/src/bwrap/provider.ts
index 1827f36bf8..e60477ac44 100644
--- a/packages/codev-sandbox/src/bwrap/provider.ts
+++ b/packages/codev-sandbox/src/bwrap/provider.ts
@@ -10,7 +10,7 @@ import {
} from "../boot"
import { stageAsset } from "../image"
import type { SandboxAsset } from "../manifest"
-import type { SandboxAvailability, SandboxConfig, SandboxProvider } from "../provider"
+import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider"
import {
GUEST_BRIDGE,
GUEST_BROKER_PORT,
@@ -58,9 +58,13 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi
const fetchImpl = options.fetchImpl ?? fetch
const startTimeoutMs = options.startTimeoutMs ?? 30_000
- const prepare = async (config: SandboxConfig, opts?: { signal?: AbortSignal }) => {
- const asset = await options.resolveImage(opts)
- const image = await stageAsset(options.cacheDir, asset, { fetchImpl, signal: opts?.signal })
+ const prepare = async (config: SandboxConfig, opts?: SandboxJobOptions) => {
+ const asset = await options.resolveImage({ signal: opts?.signal })
+ const image = await stageAsset(options.cacheDir, asset, {
+ fetchImpl,
+ signal: opts?.signal,
+ onProgress: opts?.onProgress,
+ })
const dirs = sandboxDirs(options.dataDir, config.name)
await unpackRootfs(image, dirs.rootfs, opts)
await mkdir(dirs.home, { recursive: true })
diff --git a/packages/codev-sandbox/src/controller.test.ts b/packages/codev-sandbox/src/controller.test.ts
index 1e5e0e2834..cc40335320 100644
--- a/packages/codev-sandbox/src/controller.test.ts
+++ b/packages/codev-sandbox/src/controller.test.ts
@@ -74,6 +74,84 @@ test("adds a sandbox, persists it, and reports the running server", async () =>
expect(controller.getState().job).toBe(null)
})
+test("create reports image download progress on the job", async () => {
+ let persisted: SandboxConfig[] = []
+ const seen: (number | undefined)[] = []
+ const controller = createSandboxController({
+ providers: {
+ bwrap: fakeProvider({
+ create: async (_config, opts) => {
+ opts?.onProgress?.({ bytes: 10, totalBytes: 100 })
+ opts?.onProgress?.({ bytes: 50, totalBytes: 100 })
+ },
+ }),
+ },
+ readSandboxes: () => persisted,
+ writeSandboxes: (sandboxes) => {
+ persisted = sandboxes
+ },
+ })
+ controller.subscribe((event) => seen.push(event.state.job?.progress?.bytes))
+ await controller.addSandbox(config)
+ expect(seen).toContain(10)
+ expect(seen).toContain(50)
+ expect(controller.getState().job).toBe(null)
+})
+
+test("cancelling a create aborts the download and persists nothing", async () => {
+ let persisted: SandboxConfig[] = []
+ let started = 0
+ const controller = createSandboxController({
+ providers: {
+ bwrap: fakeProvider({
+ create: (_config, opts) =>
+ new Promise((_resolve, reject) => {
+ opts?.signal?.addEventListener("abort", () => {
+ const error = new Error("aborted")
+ error.name = "AbortError"
+ reject(error)
+ })
+ }),
+ start: async () => {
+ started++
+ return fakeRunning("http://127.0.0.1:4096")
+ },
+ }),
+ },
+ readSandboxes: () => persisted,
+ writeSandboxes: (sandboxes) => {
+ persisted = sandboxes
+ },
+ })
+ const adding = controller.addSandbox(config)
+ controller.cancelJob(config.id)
+ expect(await adding).toBeUndefined()
+ expect(persisted).toEqual([])
+ expect(controller.getState().sandboxes).toEqual([])
+ expect(controller.getState().job).toBe(null)
+ expect(started).toBe(0)
+})
+
+test("cancelJob ignores a job for a different sandbox", async () => {
+ let resolveCreate: (() => void) | undefined
+ const controller = createSandboxController({
+ providers: {
+ bwrap: fakeProvider({
+ create: () =>
+ new Promise((resolve) => {
+ resolveCreate = () => resolve(undefined)
+ }),
+ }),
+ },
+ readSandboxes: () => [],
+ writeSandboxes: () => undefined,
+ })
+ const adding = controller.addSandbox(config)
+ controller.cancelJob("sandbox:other")
+ resolveCreate?.()
+ expect(await adding).toEqual(config)
+})
+
test("rejects adding the same sandbox twice", async () => {
let persisted: SandboxConfig[] = [config]
const controller = createSandboxController({
diff --git a/packages/codev-sandbox/src/controller.ts b/packages/codev-sandbox/src/controller.ts
index 64ca2aad9f..5771b792c1 100644
--- a/packages/codev-sandbox/src/controller.ts
+++ b/packages/codev-sandbox/src/controller.ts
@@ -1,9 +1,19 @@
-import type { RunningSandbox, SandboxBackend, SandboxConfig, SandboxItem, SandboxProvider, SandboxRuntime } from "./provider"
+import type {
+ RunningSandbox,
+ SandboxBackend,
+ SandboxConfig,
+ SandboxItem,
+ SandboxProgress,
+ SandboxProvider,
+ SandboxRuntime,
+} from "./provider"
-export type SandboxJob =
- | { kind: "create"; id: string; startedAt: number }
- | { kind: "destroy"; id: string; startedAt: number }
- | { kind: "reset"; id: string; startedAt: number }
+export type SandboxJob = {
+ kind: "create" | "destroy" | "reset"
+ id: string
+ startedAt: number
+ progress?: SandboxProgress
+}
export type SandboxesState = {
sandboxes: SandboxItem[]
@@ -72,10 +82,17 @@ export function createSandboxController(options: SandboxControllerOptions) {
setState({ job: null })
}
- const runJob = async (job: SandboxJob, runner: (abort: AbortController) => Promise) => {
+ const runJob = async (
+ job: SandboxJob,
+ runner: (abort: AbortController, report: (progress: SandboxProgress) => void) => Promise,
+ ) => {
const abort = beginJob(job)
+ const report = (progress: SandboxProgress) => {
+ if (jobAbort !== abort) return
+ setState({ job: { ...job, progress } })
+ }
try {
- const value = await runner(abort)
+ const value = await runner(abort, report)
endJob(abort)
return value
} catch (error) {
@@ -179,15 +196,26 @@ export function createSandboxController(options: SandboxControllerOptions) {
throw new Error(`${config.id} is already added`)
}
const provider = requireProvider(config.backend)
- await runJob({ kind: "create", id: config.id, startedAt: Date.now() }, async (abort) => {
- await provider.create(config, { signal: abort.signal })
+ const created = await runJob({ kind: "create", id: config.id, startedAt: Date.now() }, async (abort, report) => {
+ await provider.create(config, { signal: abort.signal, onProgress: report })
+ return true
})
+ // A cancelled create resolves to undefined: nothing is persisted or
+ // started, and the caller reports "cancelled" rather than an error.
+ if (!created) return undefined
options.writeSandboxes([...options.readSandboxes(), config])
setState({ sandboxes: [...state.sandboxes, { config, runtime: { kind: "starting" } }] })
void startSandbox(config.id)
return config
},
+ // Aborts the in-flight job for this sandbox (the dialog cancels a create
+ // mid-download); a mismatched or absent job is a no-op.
+ cancelJob(id: string) {
+ if (state.job?.id !== id) return
+ jobAbort?.abort()
+ },
+
async removeSandbox(id: string) {
const item = state.sandboxes.find((x) => x.config.id === id)
if (!item) return
@@ -206,9 +234,13 @@ export function createSandboxController(options: SandboxControllerOptions) {
invalidateStartAttempt(id)
stopSandboxInternal(id)
setRuntime(id, { kind: "stopped" })
- await runJob({ kind: "reset", id, startedAt: Date.now() }, async (abort) => {
- await requireProvider(item.config.backend).reset(item.config, { signal: abort.signal })
+ const done = await runJob({ kind: "reset", id, startedAt: Date.now() }, async (abort, report) => {
+ await requireProvider(item.config.backend).reset(item.config, { signal: abort.signal, onProgress: report })
+ return true
})
+ // A cancelled reset may have discarded guest state already; leave the
+ // sandbox stopped instead of starting from a half-reset image.
+ if (!done) return
await startSandbox(id)
},
diff --git a/packages/codev-sandbox/src/image.test.ts b/packages/codev-sandbox/src/image.test.ts
index cda4138abe..e18fe5d0be 100644
--- a/packages/codev-sandbox/src/image.test.ts
+++ b/packages/codev-sandbox/src/image.test.ts
@@ -1,11 +1,12 @@
import { expect, test } from "bun:test"
import { createHash } from "node:crypto"
-import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"
+import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
-import { isAssetStaged, stageAsset, stagedAssetPath } from "./image"
+import { isAssetStaged, partialAssetPath, stageAsset, stagedAssetPath } from "./image"
const payload = new TextEncoder().encode("codev sandbox image payload")
+const payloadSha256 = createHash("sha256").update(payload).digest("hex")
function servePayload() {
let requests = 0
@@ -23,12 +24,16 @@ function servePayload() {
}
}
+function partialEntries(entries: string[]) {
+ return entries.filter((entry) => entry.startsWith("partial-") || entry.endsWith(".partial"))
+}
+
test("stages an asset once, verifies it, and reuses the staged copy", async () => {
const cacheDir = await mkdtemp(join(tmpdir(), "codev-sandbox-"))
const server = servePayload()
const asset = {
url: server.url,
- sha256: createHash("sha256").update(payload).digest("hex"),
+ sha256: payloadSha256,
bytes: payload.byteLength,
}
const staged = await stageAsset(cacheDir, asset)
@@ -55,7 +60,7 @@ test("rejects a corrupted download and leaves nothing behind", async () => {
expect(stageAsset(cacheDir, asset)).rejects.toThrow("failed verification")
await Bun.sleep(20)
expect(await isAssetStaged(cacheDir, asset)).toBe(false)
- expect((await readdir(cacheDir)).filter((entry) => entry.startsWith("partial-"))).toEqual([])
+ expect(partialEntries(await readdir(cacheDir))).toEqual([])
server.stop()
await rm(cacheDir, { recursive: true, force: true })
@@ -66,7 +71,7 @@ test("rejects a truncated download by byte count", async () => {
const server = servePayload()
const asset = {
url: server.url,
- sha256: createHash("sha256").update(payload).digest("hex"),
+ sha256: payloadSha256,
bytes: payload.byteLength + 1,
}
expect(stageAsset(cacheDir, asset)).rejects.toThrow("failed verification")
@@ -84,3 +89,144 @@ test("surfaces http failures with the asset url", async () => {
server.stop(true)
await rm(cacheDir, { recursive: true, force: true })
})
+
+test("resumes an interrupted download with a range request", async () => {
+ const cacheDir = await mkdtemp(join(tmpdir(), "codev-sandbox-"))
+ const ranges: (string | null)[] = []
+ const server = Bun.serve({
+ port: 0,
+ fetch: (request) => {
+ ranges.push(request.headers.get("range"))
+ const offset = Number(/^bytes=(\d+)-$/.exec(request.headers.get("range") ?? "")?.[1] ?? 0)
+ return new Response(payload.slice(offset), {
+ status: offset > 0 ? 206 : 200,
+ headers: offset > 0 ? { "content-range": `bytes ${offset}-${payload.byteLength - 1}/${payload.byteLength}` } : {},
+ })
+ },
+ })
+ // Cut the first transfer after 10 bytes the way undici surfaces a dropped
+ // connection: chunk, then a stream error (Bun's fetch reports a clean EOF
+ // for a server-side cut, so the cut is injected client-side).
+ let calls = 0
+ const fetchImpl = (async (input: Parameters[0], init?: Parameters[1]) => {
+ calls++
+ const response = await fetch(input, init)
+ if (calls > 1) return response
+ void response.body?.cancel()
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(payload.slice(0, 10))
+ },
+ // Erroring in pull (after the chunk is consumed) matters: error()
+ // discards anything still queued, and the test needs the 10 bytes to
+ // reach disk so the retry has something to resume from.
+ pull(controller) {
+ controller.error(new TypeError("terminated"))
+ },
+ }),
+ )
+ }) as typeof fetch
+ const asset = {
+ url: `http://127.0.0.1:${server.port}/rootfs.tar`,
+ sha256: payloadSha256,
+ bytes: payload.byteLength,
+ }
+ const staged = await stageAsset(cacheDir, asset, { fetchImpl, retryDelayMs: 1 })
+ expect(new Uint8Array(await readFile(staged))).toEqual(payload)
+ expect(ranges).toEqual([null, "bytes=10-"])
+ expect(partialEntries(await readdir(cacheDir))).toEqual([])
+
+ server.stop(true)
+ await rm(cacheDir, { recursive: true, force: true })
+})
+
+test("resumes a partial left behind by an earlier run", async () => {
+ const cacheDir = await mkdtemp(join(tmpdir(), "codev-sandbox-"))
+ const asset = { url: "http://127.0.0.1:1/rootfs.tar", sha256: payloadSha256, bytes: payload.byteLength }
+ await writeFile(partialAssetPath(cacheDir, asset), payload.slice(0, 12))
+ const ranges: (string | null)[] = []
+ const server = Bun.serve({
+ port: 0,
+ fetch: (request) => {
+ ranges.push(request.headers.get("range"))
+ return new Response(payload.slice(12), {
+ status: 206,
+ headers: { "content-range": `bytes 12-${payload.byteLength - 1}/${payload.byteLength}` },
+ })
+ },
+ })
+ const resumable = { ...asset, url: `http://127.0.0.1:${server.port}/rootfs.tar` }
+ // The partial is keyed by digest, not URL, so the pre-seeded bytes are reused.
+ const staged = await stageAsset(cacheDir, resumable, { retryDelayMs: 1 })
+ expect(new Uint8Array(await readFile(staged))).toEqual(payload)
+ expect(ranges).toEqual(["bytes=12-"])
+
+ server.stop(true)
+ await rm(cacheDir, { recursive: true, force: true })
+})
+
+test("gives up after consecutive stalled attempts but keeps the partial for later", async () => {
+ const cacheDir = await mkdtemp(join(tmpdir(), "codev-sandbox-"))
+ const asset = { url: "https://example.invalid/rootfs.tar", sha256: payloadSha256, bytes: payload.byteLength }
+ await writeFile(partialAssetPath(cacheDir, asset), payload.slice(0, 12))
+ let attempts = 0
+ const fetchImpl = (() => {
+ attempts++
+ return Promise.reject(new Error("network down"))
+ }) as unknown as typeof fetch
+ expect(stageAsset(cacheDir, asset, { fetchImpl, retryDelayMs: 1 })).rejects.toThrow("stalled attempts")
+ await Bun.sleep(50)
+ expect(attempts).toBe(5)
+ expect(new Uint8Array(await readFile(partialAssetPath(cacheDir, asset)))).toEqual(payload.slice(0, 12))
+
+ await rm(cacheDir, { recursive: true, force: true })
+})
+
+test("restarts from scratch when the server rejects the range", async () => {
+ const cacheDir = await mkdtemp(join(tmpdir(), "codev-sandbox-"))
+ const asset = { url: "http://127.0.0.1:1/rootfs.tar", sha256: payloadSha256, bytes: payload.byteLength }
+ await writeFile(partialAssetPath(cacheDir, asset), new Uint8Array([9, 9, 9]))
+ const ranges: (string | null)[] = []
+ const server = Bun.serve({
+ port: 0,
+ fetch: (request) => {
+ ranges.push(request.headers.get("range"))
+ if (request.headers.get("range")) return new Response("bad range", { status: 416 })
+ return new Response(payload)
+ },
+ })
+ const staged = await stageAsset(cacheDir, { ...asset, url: `http://127.0.0.1:${server.port}/rootfs.tar` }, { retryDelayMs: 1 })
+ expect(new Uint8Array(await readFile(staged))).toEqual(payload)
+ expect(ranges).toEqual(["bytes=3-", null])
+
+ server.stop(true)
+ await rm(cacheDir, { recursive: true, force: true })
+})
+
+test("reports download progress up to the full size", async () => {
+ const cacheDir = await mkdtemp(join(tmpdir(), "codev-sandbox-"))
+ const server = servePayload()
+ const asset = { url: server.url, sha256: payloadSha256, bytes: payload.byteLength }
+ const reports: { bytes: number; totalBytes: number }[] = []
+ await stageAsset(cacheDir, asset, { onProgress: (progress) => reports.push(progress) })
+ expect(reports[0]).toEqual({ bytes: 0, totalBytes: payload.byteLength })
+ expect(reports.at(-1)).toEqual({ bytes: payload.byteLength, totalBytes: payload.byteLength })
+ expect(reports.every((report, index) => index === 0 || report.bytes >= reports[index - 1]!.bytes)).toBe(true)
+
+ server.stop()
+ await rm(cacheDir, { recursive: true, force: true })
+})
+
+test("sweeps stale partials from older downloads", async () => {
+ const cacheDir = await mkdtemp(join(tmpdir(), "codev-sandbox-"))
+ await writeFile(join(cacheDir, "partial-2b3ad998-legacy"), new Uint8Array([1]))
+ await writeFile(join(cacheDir, `${"d".repeat(64)}.partial`), new Uint8Array([2]))
+ const server = servePayload()
+ const asset = { url: server.url, sha256: payloadSha256, bytes: payload.byteLength }
+ await stageAsset(cacheDir, asset)
+ expect(partialEntries(await readdir(cacheDir))).toEqual([])
+
+ server.stop()
+ await rm(cacheDir, { recursive: true, force: true })
+})
diff --git a/packages/codev-sandbox/src/image.ts b/packages/codev-sandbox/src/image.ts
index ebe14241f2..f4a9691d44 100644
--- a/packages/codev-sandbox/src/image.ts
+++ b/packages/codev-sandbox/src/image.ts
@@ -1,9 +1,17 @@
-import { createHash, randomUUID } from "node:crypto"
-import { createWriteStream } from "node:fs"
-import { mkdir, rename, rm, stat } from "node:fs/promises"
+import { createHash } from "node:crypto"
+import { createReadStream, createWriteStream } from "node:fs"
+import { mkdir, readdir, rename, rm, stat } from "node:fs/promises"
import { finished } from "node:stream/promises"
import { basename, dirname, join } from "node:path"
import type { SandboxAsset } from "./manifest"
+import type { SandboxProgress } from "./provider"
+
+export type StageAssetOptions = {
+ fetchImpl?: typeof fetch
+ signal?: AbortSignal
+ retryDelayMs?: number
+ onProgress?: (progress: SandboxProgress) => void
+}
// Assets are staged content-addressed (//) so a
// manifest bump never clobbers an image a running sandbox still uses.
@@ -11,44 +19,172 @@ export function stagedAssetPath(cacheDir: string, asset: SandboxAsset) {
return join(cacheDir, asset.sha256, assetFileName(asset))
}
+// In-flight downloads are content-addressed too (/.partial)
+// so an interrupted download resumes where it stopped — across retries within
+// one call and across separate stageAsset calls. Images are hundreds of MB and
+// storage hosts cut long transfers, so resume is load-bearing.
+export function partialAssetPath(cacheDir: string, asset: SandboxAsset) {
+ return join(cacheDir, `${asset.sha256}.partial`)
+}
+
export async function isAssetStaged(cacheDir: string, asset: SandboxAsset) {
const file = await stat(stagedAssetPath(cacheDir, asset)).catch(() => null)
return !!file && file.isFile() && file.size === asset.bytes
}
-export async function stageAsset(
- cacheDir: string,
- asset: SandboxAsset,
- opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal },
-): Promise {
+export async function stageAsset(cacheDir: string, asset: SandboxAsset, opts?: StageAssetOptions): Promise {
if (await isAssetStaged(cacheDir, asset)) return stagedAssetPath(cacheDir, asset)
+ await mkdir(cacheDir, { recursive: true })
+ await removeStalePartials(cacheDir, asset)
+ const partial = partialAssetPath(cacheDir, asset)
+ await download(partial, asset, opts)
+ await verifyPartial(partial, asset)
+ const destination = stagedAssetPath(cacheDir, asset)
+ await mkdir(dirname(destination), { recursive: true })
+ await rename(partial, destination)
+ return destination
+}
+
+// Only this asset's partial may survive: anything else matching a partial
+// naming scheme (including the legacy partial- files) belongs to an
+// asset we are no longer downloading and can never be completed. Assumes a
+// single writer per cache dir — the desktop controller runs jobs single-flight.
+async function removeStalePartials(cacheDir: string, asset: SandboxAsset) {
+ const keep = `${asset.sha256}.partial`
+ const entries = await readdir(cacheDir).catch(() => [])
+ await Promise.all(
+ entries
+ .filter((entry) => entry !== keep && (entry.startsWith("partial-") || entry.endsWith(".partial")))
+ .map((entry) => rm(join(cacheDir, entry), { force: true })),
+ )
+}
+
+// Give up only after this many consecutive attempts with no new bytes; an
+// attempt that grew the partial resets the counter, so a flaky link can still
+// finish a large image through many resumed connections.
+const STALLED_ATTEMPT_LIMIT = 5
+
+// Progress callbacks cross an IPC boundary in the desktop app; per-chunk
+// reporting would be thousands of events for a large image.
+const PROGRESS_INTERVAL_MS = 200
+
+async function download(partial: string, asset: SandboxAsset, opts?: StageAssetOptions) {
const fetchImpl = opts?.fetchImpl ?? fetch
- const response = await fetchImpl(asset.url, { signal: opts?.signal })
+ const retryDelayMs = opts?.retryDelayMs ?? 500
+ let lastReport = 0
+ const report = (bytes: number, force = false) => {
+ if (!opts?.onProgress) return
+ const now = Date.now()
+ if (!force && now - lastReport < PROGRESS_INTERVAL_MS) return
+ lastReport = now
+ opts.onProgress({ bytes, totalBytes: asset.bytes })
+ }
+ let stalled = 0
+ while (true) {
+ const offset = await partialSize(partial)
+ report(offset, true)
+ if (offset >= asset.bytes) return
+ const failure = await downloadOnce(partial, asset, offset, fetchImpl, report, opts?.signal)
+ // A clean end that is still short means the server's file genuinely
+ // differs from the manifest; let verification fail fast instead of
+ // retrying a download that can never complete.
+ if (!failure) {
+ report(await partialSize(partial), true)
+ return
+ }
+ if (opts?.signal?.aborted) throw failure
+ stalled = (await partialSize(partial)) > offset ? 0 : stalled + 1
+ if (stalled >= STALLED_ATTEMPT_LIMIT)
+ throw new Error(
+ `sandbox asset download failed for ${asset.url} after ${STALLED_ATTEMPT_LIMIT} stalled attempts: ${failure.message}`,
+ )
+ await retryDelay(retryDelayMs * stalled, opts?.signal)
+ }
+}
+
+// One connection attempt: resume from offset when the partial has bytes,
+// restart from scratch when the server ignores the range (200) or rejects it
+// (416). Returns the failure instead of throwing when a retry could help;
+// throws on HTTP errors, which retrying the same URL cannot fix.
+async function downloadOnce(
+ partial: string,
+ asset: SandboxAsset,
+ offset: number,
+ fetchImpl: typeof fetch,
+ report: (bytes: number) => void,
+ signal?: AbortSignal,
+): Promise {
+ const response = await fetchImpl(asset.url, {
+ signal,
+ headers: offset > 0 ? { range: `bytes=${offset}-` } : undefined,
+ }).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))))
+ if (response instanceof Error) return response
+ if (response.status === 416) {
+ // The partial no longer matches what the server has; start over.
+ await rm(partial, { force: true })
+ return new Error(`sandbox asset download failed: HTTP 416 for ${asset.url}`)
+ }
if (!response.ok || !response.body)
throw new Error(`sandbox asset download failed: HTTP ${response.status} for ${asset.url}`)
- await mkdir(cacheDir, { recursive: true })
- const partial = join(cacheDir, `partial-${randomUUID()}`)
- const hash = createHash("sha256")
- const out = createWriteStream(partial)
- let bytes = 0
- for await (const chunk of response.body) {
- hash.update(chunk)
- bytes += chunk.byteLength
+ const out = createWriteStream(partial, response.status === 206 ? { flags: "a" } : {})
+ const copied = await copyBody(response.body, out, response.status === 206 ? offset : 0, asset.bytes, report).then(
+ () => null,
+ (error: unknown) => (error instanceof Error ? error : new Error(String(error))),
+ )
+ out.end()
+ await finished(out).catch(() => {})
+ return copied
+}
+
+async function copyBody(
+ body: NonNullable,
+ out: ReturnType,
+ offset: number,
+ limit: number,
+ report: (bytes: number) => void,
+) {
+ let written = offset
+ for await (const chunk of body) {
+ written += chunk.byteLength
if (!out.write(chunk)) await new Promise((resolve) => out.once("drain", () => resolve()))
+ report(written)
+ // A body longer than the manifest says can only fail verification; stop
+ // reading rather than let a broken server stream unbounded data.
+ if (written > limit) return
}
- out.end()
- await finished(out)
- const digest = hash.digest("hex")
- if (bytes !== asset.bytes || digest !== asset.sha256) {
+}
+
+async function verifyPartial(partial: string, asset: SandboxAsset) {
+ const size = await partialSize(partial)
+ if (size !== asset.bytes) {
await rm(partial, { force: true })
- throw new Error(
- `sandbox asset failed verification for ${asset.url}: expected ${asset.bytes} bytes sha256 ${asset.sha256}, got ${bytes} bytes sha256 ${digest}`,
- )
+ throw new Error(`sandbox asset failed verification for ${asset.url}: expected ${asset.bytes} bytes, got ${size}`)
}
- const destination = stagedAssetPath(cacheDir, asset)
- await mkdir(dirname(destination), { recursive: true })
- await rename(partial, destination)
- return destination
+ const hash = createHash("sha256")
+ for await (const chunk of createReadStream(partial)) hash.update(chunk as Buffer)
+ const digest = hash.digest("hex")
+ if (digest === asset.sha256) return
+ await rm(partial, { force: true })
+ throw new Error(
+ `sandbox asset failed verification for ${asset.url}: expected sha256 ${asset.sha256}, got ${digest}`,
+ )
+}
+
+async function partialSize(partial: string) {
+ const file = await stat(partial).catch(() => null)
+ return file?.isFile() ? file.size : 0
+}
+
+function retryDelay(duration: number, signal?: AbortSignal) {
+ return new Promise((resolve) => {
+ const done = () => {
+ clearTimeout(timeout)
+ signal?.removeEventListener("abort", done)
+ resolve()
+ }
+ const timeout = setTimeout(done, duration)
+ signal?.addEventListener("abort", done, { once: true })
+ })
}
function assetFileName(asset: SandboxAsset) {
diff --git a/packages/codev-sandbox/src/provider.ts b/packages/codev-sandbox/src/provider.ts
index f2150d9c79..b00c6b295b 100644
--- a/packages/codev-sandbox/src/provider.ts
+++ b/packages/codev-sandbox/src/provider.ts
@@ -32,13 +32,21 @@ export type SandboxAvailability =
| { supported: true; warning: string | null }
| { supported: false; message: string }
+// Image download progress, reported while create/reset stage the guest image.
+export type SandboxProgress = { bytes: number; totalBytes: number }
+
+export type SandboxJobOptions = {
+ signal?: AbortSignal
+ onProgress?: (progress: SandboxProgress) => void
+}
+
export type SandboxProvider = {
backend: SandboxBackend
availability(): Promise
- create(config: SandboxConfig, opts?: { signal?: AbortSignal }): Promise
+ create(config: SandboxConfig, opts?: SandboxJobOptions): Promise
start(config: SandboxConfig, opts?: { signal?: AbortSignal }): Promise
destroy(config: SandboxConfig, opts?: { signal?: AbortSignal }): Promise
- reset(config: SandboxConfig, opts?: { signal?: AbortSignal }): Promise
+ reset(config: SandboxConfig, opts?: SandboxJobOptions): Promise
}
export function sandboxServerId(name: string) {
diff --git a/packages/codev-sandbox/src/vz/provider.ts b/packages/codev-sandbox/src/vz/provider.ts
index 74deabf410..974db20d14 100644
--- a/packages/codev-sandbox/src/vz/provider.ts
+++ b/packages/codev-sandbox/src/vz/provider.ts
@@ -7,7 +7,7 @@ import { runCommand, unpackRootfs } from "../bwrap/rootfs"
import { stageAsset } from "../image"
import type { SandboxAsset } from "../manifest"
import { macosSandboxAvailability } from "../policy"
-import type { SandboxAvailability, SandboxConfig, SandboxProvider } from "../provider"
+import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider"
import { createVzHelperEventReader, type VzHelperEvent, type VzVmConfig } from "./protocol"
// Contents of the vz image tarball (built by the image pipeline).
@@ -59,9 +59,13 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider {
const startTimeoutMs = options.startTimeoutMs ?? 60_000
const readMacosVersion = options.readMacosVersion ?? defaultMacosVersion
- const prepare = async (config: SandboxConfig, opts?: { signal?: AbortSignal }) => {
- const asset = await options.resolveImage(opts)
- const image = await stageAsset(options.cacheDir, asset, { fetchImpl, signal: opts?.signal })
+ const prepare = async (config: SandboxConfig, opts?: SandboxJobOptions) => {
+ const asset = await options.resolveImage({ signal: opts?.signal })
+ const image = await stageAsset(options.cacheDir, asset, {
+ fetchImpl,
+ signal: opts?.signal,
+ onProgress: opts?.onProgress,
+ })
const dirs = vzSandboxDirs(options.dataDir, config.name)
await unpackRootfs(image, dirs.image, opts)
// initrd is optional: the pipeline's custom kernel has virtio + ext4
diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts
index 8a3d67a507..7952dd97a8 100644
--- a/packages/codev-sandbox/src/wsl/provider.ts
+++ b/packages/codev-sandbox/src/wsl/provider.ts
@@ -12,7 +12,7 @@ import {
import { stageAsset } from "../image"
import type { SandboxAsset } from "../manifest"
import { windowsBuildNumber, windowsSandboxAvailability } from "../policy"
-import type { SandboxAvailability, SandboxConfig, SandboxProvider } from "../provider"
+import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider"
import { parseDistroList, reserveLoopbackPort, runWsl, wslEnvSpec } from "./cli"
// Guest-side paths. The bundle mount point and the launcher are baked into the
@@ -93,10 +93,14 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider
await rm(wslSandboxDirs(options.dataDir, config.name).base, { recursive: true, force: true })
}
- const prepare = async (config: SandboxConfig, opts?: { signal?: AbortSignal }) => {
+ const prepare = async (config: SandboxConfig, opts?: SandboxJobOptions) => {
const distro = wslDistroName(config.name)
- const asset = await options.resolveImage(opts)
- const tarball = await stageAsset(options.cacheDir, asset, { fetchImpl, signal: opts?.signal })
+ const asset = await options.resolveImage({ signal: opts?.signal })
+ const tarball = await stageAsset(options.cacheDir, asset, {
+ fetchImpl,
+ signal: opts?.signal,
+ onProgress: opts?.onProgress,
+ })
if (await registered(distro)) return
const dirs = wslSandboxDirs(options.dataDir, config.name)
await mkdir(dirs.install, { recursive: true })
diff --git a/packages/desktop/src/main/sandbox/ipc.ts b/packages/desktop/src/main/sandbox/ipc.ts
index 168fd3cecf..203d185b0c 100644
--- a/packages/desktop/src/main/sandbox/ipc.ts
+++ b/packages/desktop/src/main/sandbox/ipc.ts
@@ -49,6 +49,10 @@ export function registerSandboxIpcHandlers(controller: DesktopSandboxController)
projectDir: requireSandboxIpcString("project directory", projectDir),
})
})
+ // Cancel takes the name (like add) so the renderer never mints sandbox ids.
+ ipcMain.handle("sandbox-cancel", (_event: IpcMainInvokeEvent, name: unknown) =>
+ controller.cancelJob(sandboxServerId(requireSandboxIpcString("sandbox name", name))),
+ )
ipcMain.handle("sandbox-remove", (_event: IpcMainInvokeEvent, id: unknown) =>
controller.removeSandbox(requireSandboxIpcString("sandbox id", id)),
)
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index f3d7c93f60..c6d4dc4c6e 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -49,6 +49,7 @@ const api: ElectronAPI = {
},
availability: () => ipcRenderer.invoke("sandbox-availability"),
add: (name, projectDir) => ipcRenderer.invoke("sandbox-add", name, projectDir),
+ cancel: (name) => ipcRenderer.invoke("sandbox-cancel", name),
remove: (id) => ipcRenderer.invoke("sandbox-remove", id),
reset: (id) => ipcRenderer.invoke("sandbox-reset", id),
start: (id) => ipcRenderer.invoke("sandbox-start", id),