From e9226f7bdefd5076aa31aa66f42ff7a9db6d7149 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Thu, 13 Aug 2026 10:40:07 +0700 Subject: [PATCH 1/2] fix(sandbox): resume interrupted image downloads and sweep stale partials The storage host cuts long transfers at arbitrary points, so the multi-hundred-MB image download used to die with undici's "TypeError: terminated" and start over on every create attempt, leaking an orphaned partial- file each time. stageAsset now stages in-flight downloads content-addressed at /.partial and resumes them with HTTP range requests, retrying until five consecutive attempts make no progress. The partial survives failures and aborts so the next create continues where the last one stopped; verification failures still delete it, and every run sweeps partials that belong to other digests, including the legacy partial- orphans. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 ++ packages/codev-sandbox/src/image.test.ts | 142 ++++++++++++++++++- packages/codev-sandbox/src/image.ts | 165 +++++++++++++++++++---- 3 files changed, 286 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 073dc1b90a..595579248f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ 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. + - **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/codev-sandbox/src/image.test.ts b/packages/codev-sandbox/src/image.test.ts index cda4138abe..755604f624 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,130 @@ 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("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..7f45cfa385 100644 --- a/packages/codev-sandbox/src/image.ts +++ b/packages/codev-sandbox/src/image.ts @@ -1,6 +1,6 @@ -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" @@ -11,6 +11,14 @@ 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 @@ -19,36 +27,145 @@ export async function isAssetStaged(cacheDir: string, asset: SandboxAsset) { export async function stageAsset( cacheDir: string, asset: SandboxAsset, - opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal }, + opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal; retryDelayMs?: number }, ): 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 + +async function download( + partial: string, + asset: SandboxAsset, + opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal; retryDelayMs?: number }, +) { const fetchImpl = opts?.fetchImpl ?? fetch - const response = await fetchImpl(asset.url, { signal: opts?.signal }) + const retryDelayMs = opts?.retryDelayMs ?? 500 + let stalled = 0 + while (true) { + const offset = await partialSize(partial) + if (offset >= asset.bytes) return + const failure = await downloadOnce(partial, asset, offset, fetchImpl, 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) 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, + 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).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, +) { + let written = offset + for await (const chunk of body) { + written += chunk.byteLength if (!out.write(chunk)) await new Promise((resolve) => out.once("drain", () => resolve())) + // 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) { From e3f6afa0e7cc1324d4d927ce9ac1cf941e7a3219 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Thu, 13 Aug 2026 10:57:24 +0700 Subject: [PATCH 2/2] feat(sandbox): show download progress and allow cancelling create The Add sandbox dialog used to show only a static "Creating..." while the multi-hundred-MB image downloaded, with Cancel disabled and no way out but waiting or an error. stageAsset now reports throttled {bytes, totalBytes} progress, threaded through every provider's create/reset into the controller's job state, so the dialog renders a progress bar with downloaded/total MB. A new sandbox-cancel IPC channel aborts the in-flight job: a cancelled create persists and starts nothing (previously an aborted create still registered the sandbox), a cancelled reset stays stopped instead of starting from a half-reset image, and the kept partial means the next attempt resumes. The dialog body also gains the horizontal padding the header and footer already had. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++ packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/es.ts | 1 + packages/app/src/sandbox/dialog-add.tsx | 48 ++++++++++-- packages/app/src/sandbox/types.ts | 8 +- packages/codev-sandbox/src/bwrap/provider.ts | 12 ++- packages/codev-sandbox/src/controller.test.ts | 78 +++++++++++++++++++ packages/codev-sandbox/src/controller.ts | 54 ++++++++++--- packages/codev-sandbox/src/image.test.ts | 14 ++++ packages/codev-sandbox/src/image.ts | 45 +++++++---- packages/codev-sandbox/src/provider.ts | 12 ++- packages/codev-sandbox/src/vz/provider.ts | 12 ++- packages/codev-sandbox/src/wsl/provider.ts | 12 ++- packages/desktop/src/main/sandbox/ipc.ts | 4 + packages/desktop/src/preload/index.ts | 1 + 15 files changed, 265 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 595579248f..b6704c64e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,13 @@ file is the human-readable summary of what changed and why. 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 ( {language.t("sandbox.server.add")} - + {unsupportedMessage()}

} @@ -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 755604f624..e18fe5d0be 100644 --- a/packages/codev-sandbox/src/image.test.ts +++ b/packages/codev-sandbox/src/image.test.ts @@ -204,6 +204,20 @@ test("restarts from scratch when the server rejects the range", async () => { 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])) diff --git a/packages/codev-sandbox/src/image.ts b/packages/codev-sandbox/src/image.ts index 7f45cfa385..f4a9691d44 100644 --- a/packages/codev-sandbox/src/image.ts +++ b/packages/codev-sandbox/src/image.ts @@ -4,6 +4,14 @@ 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. @@ -24,11 +32,7 @@ export async function isAssetStaged(cacheDir: string, asset: SandboxAsset) { return !!file && file.isFile() && file.size === asset.bytes } -export async function stageAsset( - cacheDir: string, - asset: SandboxAsset, - opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal; retryDelayMs?: number }, -): 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) @@ -60,22 +64,34 @@ async function removeStalePartials(cacheDir: string, asset: SandboxAsset) { // finish a large image through many resumed connections. const STALLED_ATTEMPT_LIMIT = 5 -async function download( - partial: string, - asset: SandboxAsset, - opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal; retryDelayMs?: number }, -) { +// 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 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, opts?.signal) + 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) return + 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) @@ -95,6 +111,7 @@ async function downloadOnce( asset: SandboxAsset, offset: number, fetchImpl: typeof fetch, + report: (bytes: number) => void, signal?: AbortSignal, ): Promise { const response = await fetchImpl(asset.url, { @@ -110,7 +127,7 @@ async function downloadOnce( if (!response.ok || !response.body) throw new Error(`sandbox asset download failed: HTTP ${response.status} for ${asset.url}`) const out = createWriteStream(partial, response.status === 206 ? { flags: "a" } : {}) - const copied = await copyBody(response.body, out, response.status === 206 ? offset : 0, asset.bytes).then( + 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))), ) @@ -124,11 +141,13 @@ async function copyBody( 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 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),