diff --git a/.changeset/tidy-boxd-cancellation.md b/.changeset/tidy-boxd-cancellation.md new file mode 100644 index 0000000000..7e1d0b553f --- /dev/null +++ b/.changeset/tidy-boxd-cancellation.md @@ -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. diff --git a/docs/sandbox/providers.md b/docs/sandbox/providers.md index b968c77ed7..86a6f330a1 100644 --- a/docs/sandbox/providers.md +++ b/docs/sandbox/providers.md @@ -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, diff --git a/packages/ai-sandbox-boxd/src/provider.ts b/packages/ai-sandbox-boxd/src/provider.ts index 8f2bbc10db..e86c68a7f2 100644 --- a/packages/ai-sandbox-boxd/src/provider.ts +++ b/packages/ai-sandbox-boxd/src/provider.ts @@ -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()}`, diff --git a/packages/ai-sandbox-boxd/tests/provider.test.ts b/packages/ai-sandbox-boxd/tests/provider.test.ts index daaa0793a2..32904757c8 100644 --- a/packages/ai-sandbox-boxd/tests/provider.test.ts +++ b/packages/ai-sandbox-boxd/tests/provider.test.ts @@ -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({ diff --git a/testing/e2e/tests/sandbox-boxd-startup-cancellation.spec.ts b/testing/e2e/tests/sandbox-boxd-startup-cancellation.spec.ts new file mode 100644 index 0000000000..650cde83fe --- /dev/null +++ b/testing/e2e/tests/sandbox-boxd-startup-cancellation.spec.ts @@ -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') +})