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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
48 changes: 43 additions & 5 deletions packages/app/src/sandbox/dialog-add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -60,13 +70,21 @@ export function DialogAddSandbox() {
}
}

const cancel = () => {
if (!store.adding) {
dialog.close()
return
}
void api.cancel(store.name)
}

return (
<Dialog fit>
<DialogHeader hideClose={true}>
<DialogTitle>{language.t("sandbox.server.add")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex flex-col gap-3 pt-3">
<DialogBody class="flex flex-col gap-3 px-4 pt-3 pb-1">
<Show
when={availability()?.supported !== false}
fallback={<p class="text-14 text-v2-text-text-muted">{unsupportedMessage()}</p>}
Expand Down Expand Up @@ -96,11 +114,31 @@ export function DialogAddSandbox() {
{language.t("sandbox.add.choose")}
</ButtonV2>
</div>
<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.add.downloadHint")}</p>
<Show
when={progress()}
fallback={<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.add.downloadHint")}</p>}
>
{(current) => (
<div class="flex flex-col gap-1.5">
<div class="h-1 w-full overflow-hidden rounded-full bg-v2-background-bg-layer-04">
<div
class="h-full rounded-full bg-v2-icon-icon-base transition-[width] duration-200"
style={{ width: `${Math.min(100, (current().bytes / current().totalBytes) * 100)}%` }}
/>
</div>
<p class="text-12 text-v2-text-text-muted">
{language.t("sandbox.add.downloadProgress", {
done: Math.round(current().bytes / 1024 / 1024),
total: Math.round(current().totalBytes / 1024 / 1024),
})}
</p>
</div>
)}
</Show>
</Show>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={store.adding} onClick={() => dialog.close()}>
<ButtonV2 variant="neutral" onClick={cancel}>
{language.t("common.cancel")}
</ButtonV2>
<Show when={availability()?.supported !== false}>
Expand Down
8 changes: 7 additions & 1 deletion packages/app/src/sandbox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -44,7 +47,10 @@ export type SandboxesPlatform = {
getState(): Promise<SandboxesState>
subscribe(cb: (event: SandboxesEvent) => void): () => void
availability(): Promise<SandboxAvailability>
add(name: string, projectDir: string): Promise<SandboxConfig>
// Resolves undefined when the create was cancelled mid-download.
add(name: string, projectDir: string): Promise<SandboxConfig | undefined>
// Aborts the in-flight create/reset job for this sandbox name, if any.
cancel(name: string): Promise<void>
remove(id: string): Promise<void>
reset(id: string): Promise<void>
start(id: string): Promise<void>
Expand Down
12 changes: 8 additions & 4 deletions packages/codev-sandbox/src/bwrap/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 })
Expand Down
78 changes: 78 additions & 0 deletions packages/codev-sandbox/src/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
54 changes: 43 additions & 11 deletions packages/codev-sandbox/src/controller.ts
Original file line number Diff line number Diff line change
@@ -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[]
Expand Down Expand Up @@ -72,10 +82,17 @@ export function createSandboxController(options: SandboxControllerOptions) {
setState({ job: null })
}

const runJob = async <T>(job: SandboxJob, runner: (abort: AbortController) => Promise<T>) => {
const runJob = async <T>(
job: SandboxJob,
runner: (abort: AbortController, report: (progress: SandboxProgress) => void) => Promise<T>,
) => {
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) {
Expand Down Expand Up @@ -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
Expand All @@ -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)
},

Expand Down
Loading
Loading