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
5 changes: 5 additions & 0 deletions .changeset/tidy-boxd-cancellation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-sandbox-boxd': patch
---

Delete newly created machines when create or snapshot restore is aborted during readiness or workspace setup.
3 changes: 3 additions & 0 deletions docs/sandbox/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,9 @@ const boxd = boxdSandbox({
100 GB disk.
- **Working directory:** the portable root `/workspace` maps to
`/home/boxd/workspace`. Override with `workdir`.
- **Cancellation:** if you abort create or snapshot restore during startup,
the provider tries to delete the new machine after the current SDK call
finishes.
- **Processes:** `spawn()` opens a streaming exec with separate stdout and
stderr and a writable stdin. `kill()` signals the process group inside the
machine and verifies that it is gone, so `killableProcesses` is measured,
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-sandbox-boxd/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,14 @@ class BoxdProvider implements SandboxProvider {
try {
input.signal?.throwIfAborted()
const ready = await this.client.machines.waitUntilReady(machine.id)
input.signal?.throwIfAborted()
const handle = this.handle(ready, input.env)
// From `/`: the workdir does not exist yet, and every handle exec `cd`s
// into its cwd first.
const mkdir = await handle.process.exec(`mkdir -p ${q(this.workdir)}`, {
cwd: '/',
})
input.signal?.throwIfAborted()
if (mkdir.exitCode !== 0) {
throw new Error(
`boxd: failed to create workspace directory "${this.workdir}" (exit ${mkdir.exitCode}): ${mkdir.stderr.trim()}`,
Expand Down
32 changes: 32 additions & 0 deletions packages/ai-sandbox-boxd/tests/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,38 @@ afterEach(() => {
delete process.env.BOXD_ORG
})

describe('boxdSandbox provider: cancellation during adoption', () => {
it('rejects create aborted during readiness and deletes its machine', async () => {
const controller = new AbortController()
const reason = new Error('adoption cancelled')
machines.waitUntilReady.mockImplementation(async () => {
controller.abort(reason)
return machine()
})
await expect(
boxdSandbox({ apiKey: 'k' }).create({ signal: controller.signal }),
).rejects.toBe(reason)
expect(machines.delete).toHaveBeenCalledExactlyOnceWith('vm-1')
expect(machines.exec).not.toHaveBeenCalled()
})

it('rejects restore aborted during workspace setup and deletes its machine', async () => {
const controller = new AbortController()
const reason = new Error('adoption cancelled')
machines.exec.mockImplementation(async () => {
controller.abort(reason)
return ok
})
await expect(
boxdSandbox({ apiKey: 'k' }).restoreSnapshot!({
snapshotId: 'saved-workspace',
signal: controller.signal,
}),
).rejects.toBe(reason)
expect(machines.delete).toHaveBeenCalledExactlyOnceWith('vm-1')
})
})

describe('boxdSandbox provider: create', () => {
it('creates an isolated machine named after the deterministic id, in the org, at the requested size', async () => {
const handle = await boxdSandbox({
Expand Down
47 changes: 47 additions & 0 deletions testing/e2e/tests/sandbox-boxd-startup-cancellation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { spawnSync } from 'node:child_process'
import { expect, test } from '@playwright/test'

test('boxd create cleans up an aborted startup in the built package', () => {
const result = spawnSync(
process.execPath,
[
'--input-type=module',
'--eval',
`
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { boxdSandbox } from './packages/ai-sandbox-boxd/dist/esm/index.js'

const require = createRequire(new URL('./packages/ai-sandbox-boxd/package.json', import.meta.url))
const { Boxd } = await import(require.resolve('@boxd-sh/sdk'))
const client = new Boxd({ token: 'unused-test-token' })
const machines = Object.getPrototypeOf(client.machines)
const machine = { id: 'vm-1', name: 'm', status: 'running', access: { url: 'https://m.boxd.sh' } }

const controller = new AbortController()
const reason = new Error('startup cancelled')
const deleted = []
machines.create = async () => machine
machines.waitUntilReady = async () => {
controller.abort(reason)
return machine
}
machines.delete = async (id) => { deleted.push(id) }
const result = boxdSandbox({ apiKey: 'unused-test-key' }).create({ signal: controller.signal })
await assert.rejects(result, (error) => error === reason)
assert.deepEqual(deleted, ['vm-1'])
await client.close()
console.log('create cancelled; machine deleted')
`,
],
{
cwd: new URL('../../../', import.meta.url),
encoding: 'utf8',
timeout: 20_000,
},
)

expect(result.error).toBeUndefined()
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('create cancelled; machine deleted')
})
Loading