From 3d8cb4094a1088eaa8bee442feecc553bc17f923 Mon Sep 17 00:00:00 2001 From: Tyagiquamar Date: Sat, 3 Oct 2026 01:49:11 +0530 Subject: [PATCH 1/2] fix(ai-sandbox-boxd): clean up cancelled sandbox startup --- .changeset/tidy-boxd-cancellation.md | 5 ++ docs/sandbox/providers.md | 2 + packages/ai-sandbox-boxd/src/provider.ts | 2 + .../ai-sandbox-boxd/tests/provider.test.ts | 64 +++++++++++++++++++ .../sandbox-boxd-startup-cancellation.spec.ts | 63 ++++++++++++++++++ 5 files changed, 136 insertions(+) create mode 100644 .changeset/tidy-boxd-cancellation.md create mode 100644 testing/e2e/tests/sandbox-boxd-startup-cancellation.spec.ts 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..34b6d19d50 100644 --- a/docs/sandbox/providers.md +++ b/docs/sandbox/providers.md @@ -436,6 +436,8 @@ 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 deletes 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..53a968b11e 100644 --- a/packages/ai-sandbox-boxd/tests/provider.test.ts +++ b/packages/ai-sandbox-boxd/tests/provider.test.ts @@ -6,6 +6,10 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NotFoundError } from '@boxd-sh/sdk' +import { + InMemorySandboxInstanceStore, + defineSandbox, +} from '@tanstack/ai-sandbox' import { boxdSandbox } from '../src/index' const { machines, snapshots, ctorArgs } = vi.hoisted(() => ({ @@ -70,6 +74,66 @@ afterEach(() => { delete process.env.BOXD_ORG }) +describe('boxdSandbox provider: cancellation during adoption', () => { + it('does not persist a machine when ensure is aborted during startup', async () => { + const controller = new AbortController() + const reason = new Error('ensure cancelled') + machines.waitUntilReady.mockImplementation(async () => { + controller.abort(reason) + return machine() + }) + const store = new InMemorySandboxInstanceStore() + const sandbox = defineSandbox({ + id: 'cancelled-startup', + provider: boxdSandbox({ apiKey: 'k' }), + lifecycle: { snapshot: 'none' }, + }) + const ctx = { + threadId: 'thread-1', + runId: 'run-1', + signal: controller.signal, + store, + } + await expect(sandbox.ensure(ctx)).rejects.toBe(reason) + expect(await store.get(sandbox.key(ctx))).toBeNull() + expect(machines.delete).toHaveBeenCalledExactlyOnceWith('vm-1') + }) + + it.each([ + ['create', 'readiness'], + ['create', 'workspace setup'], + ['restore', 'readiness'], + ['restore', 'workspace setup'], + ] as const)( + 'rejects %s aborted during %s and deletes its machine', + async (operation, stage) => { + const controller = new AbortController() + const reason = new Error('adoption cancelled') + machines.waitUntilReady.mockImplementation(async () => { + if (stage === 'readiness') controller.abort(reason) + return machine() + }) + machines.exec.mockImplementation(async () => { + if (stage === 'workspace setup') controller.abort(reason) + return ok + }) + const provider = boxdSandbox({ apiKey: 'k' }) + if (!provider.restoreSnapshot) + throw new Error('snapshot restore unavailable') + const result = + operation === 'create' + ? provider.create({ signal: controller.signal }) + : provider.restoreSnapshot({ + snapshotId: 'saved-workspace', + signal: controller.signal, + }) + await expect(result).rejects.toBe(reason) + expect(machines.delete).toHaveBeenCalledExactlyOnceWith('vm-1') + if (stage === 'readiness') expect(machines.exec).not.toHaveBeenCalled() + }, + ) +}) + 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..f53d41c57f --- /dev/null +++ b/testing/e2e/tests/sandbox-boxd-startup-cancellation.spec.ts @@ -0,0 +1,63 @@ +import { spawnSync } from 'node:child_process' +import { expect, test } from '@playwright/test' + +test('boxd create and restore clean 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' } } + + for (const operation of ['create', 'restore']) { + for (const stage of ['readiness', 'workspace setup']) { + const controller = new AbortController() + const reason = new Error('startup cancelled') + const deleted = [] + let executions = 0 + machines.create = async () => machine + machines.waitUntilReady = async () => { + if (stage === 'readiness') controller.abort(reason) + return machine + } + machines.exec = async () => { + executions++ + if (stage === 'workspace setup') controller.abort(reason) + return { stdout: '', stderr: '', exitCode: 0, success: true } + } + machines.delete = async (id) => { deleted.push(id) } + const provider = boxdSandbox({ apiKey: 'unused-test-key' }) + const result = operation === 'create' + ? provider.create({ signal: controller.signal }) + : provider.restoreSnapshot({ snapshotId: 'saved', signal: controller.signal }) + await assert.rejects(result, (error) => error === reason) + assert.deepEqual(deleted, ['vm-1']) + assert.equal(executions, stage === 'readiness' ? 0 : 1) + } + } + await client.close() + console.log('create and restore cancelled; machines 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 and restore cancelled; machines deleted', + ) +}) From be602cd8969bd26e28f15c36922254b6d06345c1 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:14:48 +1100 Subject: [PATCH 2/2] test(ai-sandbox-boxd): trim cancellation tests to the cases that pin the fix Keep one unit test for each abort check and one E2E case against the built package. State in the docs that the delete is best effort. --- docs/sandbox/providers.md | 3 +- .../ai-sandbox-boxd/tests/provider.test.ts | 74 ++++++------------- .../sandbox-boxd-startup-cancellation.spec.ts | 44 ++++------- 3 files changed, 37 insertions(+), 84 deletions(-) diff --git a/docs/sandbox/providers.md b/docs/sandbox/providers.md index 34b6d19d50..86a6f330a1 100644 --- a/docs/sandbox/providers.md +++ b/docs/sandbox/providers.md @@ -437,7 +437,8 @@ const boxd = boxdSandbox({ - **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 deletes the new machine after the current SDK call finishes. + 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/tests/provider.test.ts b/packages/ai-sandbox-boxd/tests/provider.test.ts index 53a968b11e..32904757c8 100644 --- a/packages/ai-sandbox-boxd/tests/provider.test.ts +++ b/packages/ai-sandbox-boxd/tests/provider.test.ts @@ -6,10 +6,6 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NotFoundError } from '@boxd-sh/sdk' -import { - InMemorySandboxInstanceStore, - defineSandbox, -} from '@tanstack/ai-sandbox' import { boxdSandbox } from '../src/index' const { machines, snapshots, ctorArgs } = vi.hoisted(() => ({ @@ -75,63 +71,35 @@ afterEach(() => { }) describe('boxdSandbox provider: cancellation during adoption', () => { - it('does not persist a machine when ensure is aborted during startup', async () => { + it('rejects create aborted during readiness and deletes its machine', async () => { const controller = new AbortController() - const reason = new Error('ensure cancelled') + const reason = new Error('adoption cancelled') machines.waitUntilReady.mockImplementation(async () => { controller.abort(reason) return machine() }) - const store = new InMemorySandboxInstanceStore() - const sandbox = defineSandbox({ - id: 'cancelled-startup', - provider: boxdSandbox({ apiKey: 'k' }), - lifecycle: { snapshot: 'none' }, - }) - const ctx = { - threadId: 'thread-1', - runId: 'run-1', - signal: controller.signal, - store, - } - await expect(sandbox.ensure(ctx)).rejects.toBe(reason) - expect(await store.get(sandbox.key(ctx))).toBeNull() + await expect( + boxdSandbox({ apiKey: 'k' }).create({ signal: controller.signal }), + ).rejects.toBe(reason) expect(machines.delete).toHaveBeenCalledExactlyOnceWith('vm-1') + expect(machines.exec).not.toHaveBeenCalled() }) - it.each([ - ['create', 'readiness'], - ['create', 'workspace setup'], - ['restore', 'readiness'], - ['restore', 'workspace setup'], - ] as const)( - 'rejects %s aborted during %s and deletes its machine', - async (operation, stage) => { - const controller = new AbortController() - const reason = new Error('adoption cancelled') - machines.waitUntilReady.mockImplementation(async () => { - if (stage === 'readiness') controller.abort(reason) - return machine() - }) - machines.exec.mockImplementation(async () => { - if (stage === 'workspace setup') controller.abort(reason) - return ok - }) - const provider = boxdSandbox({ apiKey: 'k' }) - if (!provider.restoreSnapshot) - throw new Error('snapshot restore unavailable') - const result = - operation === 'create' - ? provider.create({ signal: controller.signal }) - : provider.restoreSnapshot({ - snapshotId: 'saved-workspace', - signal: controller.signal, - }) - await expect(result).rejects.toBe(reason) - expect(machines.delete).toHaveBeenCalledExactlyOnceWith('vm-1') - if (stage === 'readiness') 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', () => { diff --git a/testing/e2e/tests/sandbox-boxd-startup-cancellation.spec.ts b/testing/e2e/tests/sandbox-boxd-startup-cancellation.spec.ts index f53d41c57f..650cde83fe 100644 --- a/testing/e2e/tests/sandbox-boxd-startup-cancellation.spec.ts +++ b/testing/e2e/tests/sandbox-boxd-startup-cancellation.spec.ts @@ -1,7 +1,7 @@ import { spawnSync } from 'node:child_process' import { expect, test } from '@playwright/test' -test('boxd create and restore clean up an aborted startup in the built package', () => { +test('boxd create cleans up an aborted startup in the built package', () => { const result = spawnSync( process.execPath, [ @@ -18,34 +18,20 @@ test('boxd create and restore clean up an aborted startup in the built package', const machines = Object.getPrototypeOf(client.machines) const machine = { id: 'vm-1', name: 'm', status: 'running', access: { url: 'https://m.boxd.sh' } } - for (const operation of ['create', 'restore']) { - for (const stage of ['readiness', 'workspace setup']) { - const controller = new AbortController() - const reason = new Error('startup cancelled') - const deleted = [] - let executions = 0 - machines.create = async () => machine - machines.waitUntilReady = async () => { - if (stage === 'readiness') controller.abort(reason) - return machine - } - machines.exec = async () => { - executions++ - if (stage === 'workspace setup') controller.abort(reason) - return { stdout: '', stderr: '', exitCode: 0, success: true } - } - machines.delete = async (id) => { deleted.push(id) } - const provider = boxdSandbox({ apiKey: 'unused-test-key' }) - const result = operation === 'create' - ? provider.create({ signal: controller.signal }) - : provider.restoreSnapshot({ snapshotId: 'saved', signal: controller.signal }) - await assert.rejects(result, (error) => error === reason) - assert.deepEqual(deleted, ['vm-1']) - assert.equal(executions, stage === 'readiness' ? 0 : 1) - } + 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 and restore cancelled; machines deleted') + console.log('create cancelled; machine deleted') `, ], { @@ -57,7 +43,5 @@ test('boxd create and restore clean up an aborted startup in the built package', expect(result.error).toBeUndefined() expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain( - 'create and restore cancelled; machines deleted', - ) + expect(result.stdout).toContain('create cancelled; machine deleted') })