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 ( {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 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),