Skip to content

Commit 2994141

Browse files
icecrasher321claude
andcommitted
feat(sandboxes): repair a missing image at create, where the truth is observable
Six review rounds narrowed the window between deleting a shared template and another workspace adopting its content hash, and each fix exposed the next facet. They all share a cause: the registry row and the provider template are two systems with no shared transaction, so any scheme that keeps them in step is guessing. Create is the one step that does not have to guess. It either gets a sandbox or it does not, so a `ready` row pointing at a deleted template now corrects itself the first time it is used, rather than needing someone to re-save the sandbox. - `SandboxImageBuilder.isMissingImage` asks the provider to classify its own failure. Prebuilt-only, because a runtime provider has no image to miss - E2B answers it off `NotFoundError`, which the SDK maps from a 404. The only resource a create names is the template, and the two subclasses that describe other calls — a missing file, an exited sandbox — are excluded. The classifier stays deliberately narrow: treating auth or rate-limit failures as a missing image would turn a provider outage into a build storm - `repairMissingSandboxImage` invalidates the cache, rebuilds with `imageKnownGone` (no cooldown, since this observed the image is gone rather than inferring it), and returns copy telling the author to run again - `ResolvedSandbox` carries `specHash` so the failing execution can name what to rebuild This subsumes the open facets rather than adding another guard beside them: the stale per-replica cache, an adopter left `ready` against a deleted ref, and a rebuild that never took all end at the same place — the next run repairs itself. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f09dc99 commit 2994141

5 files changed

Lines changed: 178 additions & 10 deletions

File tree

apps/sim/lib/execution/remote-sandbox/e2b.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,27 @@ const e2bImages: SandboxImageBuilder = {
256256
* endpoint directly. A non-2xx throws so the caller leaves the registry row in
257257
* place and retries, rather than orphaning the remote template.
258258
*/
259+
/**
260+
* E2B maps a 404 from the control plane to `NotFoundError`, and the only resource
261+
* a create request names is the template — so a 404 there means the ref is gone.
262+
*
263+
* Its two subclasses are excluded because they describe other calls entirely: a
264+
* missing file inside a running sandbox, or a sandbox that has already exited.
265+
* Neither is reachable from a create. Everything else — auth, rate limit,
266+
* transport — is deliberately not a missing image, since rebuilding on those
267+
* would turn a provider outage into a build storm.
268+
*/
269+
async isMissingImage(error: unknown): Promise<boolean> {
270+
const { FileNotFoundError, NotFoundError, SandboxNotFoundError } = await import(
271+
'@e2b/code-interpreter'
272+
)
273+
return (
274+
error instanceof NotFoundError &&
275+
!(error instanceof SandboxNotFoundError) &&
276+
!(error instanceof FileNotFoundError)
277+
)
278+
},
279+
259280
async deleteImage(build: SandboxImageBuild): Promise<void> {
260281
const apiKey = env.E2B_API_KEY
261282
if (!apiKey) {

apps/sim/lib/execution/remote-sandbox/index.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
provisionRuntimeDependencies,
77
type ResolvedSandbox,
88
RUNTIME_INSTALL_TIMEOUT_MS,
9+
repairMissingSandboxImage,
910
resolveWorkspaceSandbox,
1011
} from '@/lib/execution/remote-sandbox/resolve'
1112
import type {
@@ -38,6 +39,31 @@ async function createSandbox(
3839
return sandbox
3940
}
4041

42+
/**
43+
* Creates a sandbox, turning "that image is gone" into a rebuild rather than a
44+
* failure the author has to resolve by hand.
45+
*
46+
* Create is the only step that observes whether the provider image really exists,
47+
* which is why the repair hangs off it: the registry row and the remote template
48+
* are two systems with no shared transaction, so keeping them in step is always
49+
* best-effort, while checking at the point of use is not. Any other failure is
50+
* rethrown untouched.
51+
*/
52+
async function createSelectedSandbox(
53+
kind: SandboxKind,
54+
options: CreateSandboxOptions,
55+
selected: ResolvedSandbox | null
56+
): Promise<SandboxHandle> {
57+
try {
58+
return await createSandbox(kind, options)
59+
} catch (error) {
60+
if (!selected) throw error
61+
const rebuilding = await repairMissingSandboxImage(selected, error)
62+
if (!rebuilding) throw error
63+
throw new Error(rebuilding)
64+
}
65+
}
66+
4167
/**
4268
* Materializes sandbox input files before user code runs. `content` entries are written inline;
4369
* `url` entries are fetched from inside the sandbox via `curl` — their bytes never pass through the
@@ -288,7 +314,11 @@ export async function executeInSandbox(
288314
sandboxId: req.sandboxId,
289315
})
290316

291-
const sandbox = await createSandbox(kind, { language, imageRef: selected?.imageRef })
317+
const sandbox = await createSelectedSandbox(
318+
kind,
319+
{ language, imageRef: selected?.imageRef },
320+
selected
321+
)
292322
const sandboxId = sandbox.sandboxId
293323

294324
try {
@@ -377,7 +407,7 @@ export async function executeShellInSandbox(
377407
sandboxId: req.sandboxId,
378408
})
379409

380-
const sandbox = await createSandbox(kind, { imageRef: selected?.imageRef })
410+
const sandbox = await createSelectedSandbox(kind, { imageRef: selected?.imageRef }, selected)
381411
const sandboxId = sandbox.sandboxId
382412

383413
try {

apps/sim/lib/execution/remote-sandbox/resolve.test.ts

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
import { beforeEach, describe, expect, it, vi } from 'vitest'
99
import { CodeLanguage } from '@/lib/execution/languages'
1010

11-
const { mockSelect, mockUpdate, mockProviderStrategy, mockEnsureSandboxImage } = vi.hoisted(() => ({
12-
mockSelect: vi.fn(),
13-
mockUpdate: vi.fn(),
14-
mockProviderStrategy: { current: 'prebuilt' as 'prebuilt' | 'runtime' },
15-
mockEnsureSandboxImage: vi.fn(),
16-
}))
11+
const { mockSelect, mockUpdate, mockProviderStrategy, mockEnsureSandboxImage, mockIsMissingImage } =
12+
vi.hoisted(() => ({
13+
mockSelect: vi.fn(),
14+
mockUpdate: vi.fn(),
15+
mockProviderStrategy: { current: 'prebuilt' as 'prebuilt' | 'runtime' },
16+
mockEnsureSandboxImage: vi.fn(),
17+
mockIsMissingImage: vi.fn(),
18+
}))
1719

1820
vi.mock('@/lib/execution/remote-sandbox/image-registry', () => ({
1921
ensureSandboxImage: mockEnsureSandboxImage,
@@ -57,12 +59,18 @@ vi.mock('@/lib/execution/remote-sandbox/provider', () => ({
5759
get dependencyStrategy() {
5860
return mockProviderStrategy.current
5961
},
62+
get images() {
63+
return mockProviderStrategy.current === 'prebuilt'
64+
? { isMissingImage: mockIsMissingImage }
65+
: undefined
66+
},
6067
}),
6168
}))
6269

6370
import {
6471
invalidateSandboxResolution,
6572
provisionRuntimeDependencies,
73+
repairMissingSandboxImage,
6674
resolveWorkspaceSandbox,
6775
} from '@/lib/execution/remote-sandbox/resolve'
6876

@@ -368,3 +376,58 @@ describe('provisionRuntimeDependencies', () => {
368376
expect(options.timeoutMs).toBeGreaterThan(0)
369377
})
370378
})
379+
380+
/**
381+
* The registry and the provider template are two systems with no shared
382+
* transaction, so every attempt to keep them in step leaves some window. Create is
383+
* the one step that observes the truth, which is why the repair hangs off it.
384+
*/
385+
describe('repairMissingSandboxImage', () => {
386+
const SELECTED = {
387+
id: 'sbx-1',
388+
name: 'bigquery-etl',
389+
language: CodeLanguage.Python,
390+
dependencies: ['pandas'],
391+
specHash: 'hash-1',
392+
strategy: 'prebuilt' as const,
393+
imageRef: 'sim-sbx-abc',
394+
}
395+
396+
it('rebuilds without a cooldown, because create observed the image is gone', async () => {
397+
mockIsMissingImage.mockResolvedValue(true)
398+
399+
const message = await repairMissingSandboxImage(SELECTED, new Error('404'))
400+
401+
expect(message).toMatch(/being rebuilt/)
402+
expect(mockEnsureSandboxImage).toHaveBeenCalledWith(
403+
{ language: 'python', dependencies: ['pandas'] },
404+
'hash-1',
405+
{ imageKnownGone: true }
406+
)
407+
})
408+
409+
/**
410+
* The classifier has to stay narrow: treating an auth or rate-limit failure as a
411+
* missing image would rebuild every sandbox on a provider outage.
412+
*/
413+
it('leaves any other provider failure alone', async () => {
414+
mockIsMissingImage.mockResolvedValue(false)
415+
416+
const message = await repairMissingSandboxImage(SELECTED, new Error('rate limited'))
417+
418+
expect(message).toBeNull()
419+
expect(mockEnsureSandboxImage).not.toHaveBeenCalled()
420+
})
421+
422+
it('does nothing for a runtime-strategy sandbox, which has no image to miss', async () => {
423+
mockIsMissingImage.mockResolvedValue(true)
424+
425+
const message = await repairMissingSandboxImage(
426+
{ ...SELECTED, strategy: 'runtime', imageRef: undefined },
427+
new Error('404')
428+
)
429+
430+
expect(message).toBeNull()
431+
expect(mockEnsureSandboxImage).not.toHaveBeenCalled()
432+
})
433+
})

apps/sim/lib/execution/remote-sandbox/resolve.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ export interface ResolvedSandbox {
7575
name: string
7676
language: SandboxLanguage
7777
dependencies: string[]
78+
/** Content address of the package set, so a failed create can rebuild it. */
79+
specHash: string
7880
strategy: SandboxDependencyStrategy
7981
/** Provider image to create from. Present under the `prebuilt` strategy. */
8082
imageRef?: string
@@ -194,6 +196,7 @@ export async function resolveWorkspaceSandbox(args: {
194196
name: row.name,
195197
language: row.language,
196198
dependencies: row.dependencies ?? [],
199+
specHash: row.specHash,
197200
envs: envsFor(row.language),
198201
}
199202

@@ -294,7 +297,8 @@ async function readImage(providerId: string, specHash: string): Promise<CachedIm
294297
*/
295298
async function scheduleImageRepair(
296299
spec: { language: SandboxLanguage; dependencies: string[] },
297-
specHash: string
300+
specHash: string,
301+
options?: { imageKnownGone?: boolean }
298302
): Promise<void> {
299303
try {
300304
const { ensureSandboxImage, FAILED_BUILD_RETRY_COOLDOWN_MS } = await import(
@@ -303,13 +307,52 @@ async function scheduleImageRepair(
303307
await ensureSandboxImage(
304308
{ language: spec.language, dependencies: spec.dependencies },
305309
specHash,
306-
{ minFailureAgeMs: FAILED_BUILD_RETRY_COOLDOWN_MS }
310+
// A create that just failed on a missing image has observed the truth, so it
311+
// reclaims whatever the row says and skips the cooldown. Resolution reading a
312+
// row it cannot verify only gets the rate-limited retry.
313+
options?.imageKnownGone
314+
? { imageKnownGone: true }
315+
: { minFailureAgeMs: FAILED_BUILD_RETRY_COOLDOWN_MS }
307316
)
308317
} catch (error) {
309318
logger.warn('Failed to schedule sandbox image repair', { specHash, error })
310319
}
311320
}
312321

322+
/**
323+
* Repairs a sandbox whose image turned out to be gone when the provider was asked
324+
* to create from it.
325+
*
326+
* This is the backstop the registry cannot be: the row and the provider template
327+
* are two systems with no shared transaction, so every attempt to keep them in step
328+
* leaves some window — a released image adopted mid-delete, a stale cache on another
329+
* replica, a rebuild that did not take. Create is the one place that observes ground
330+
* truth, so a `ready` row pointing at nothing repairs itself here on first use
331+
* instead of needing someone to re-save the sandbox.
332+
*
333+
* Returns the message to fail this execution with, or `null` when the failure was
334+
* anything else and must surface unchanged.
335+
*/
336+
export async function repairMissingSandboxImage(
337+
selected: ResolvedSandbox,
338+
error: unknown
339+
): Promise<string | null> {
340+
if (selected.strategy !== 'prebuilt' || !selected.imageRef) return null
341+
342+
const provider = resolveProvider()
343+
if (!provider.images) return null
344+
if (!(await provider.images.isMissingImage(error))) return null
345+
346+
invalidateSandboxResolution()
347+
await scheduleImageRepair(selected, selected.specHash, { imageKnownGone: true })
348+
logger.warn('Sandbox image was missing at create; rebuilding it', {
349+
sandbox: selected.name,
350+
specHash: selected.specHash,
351+
})
352+
353+
return `Sandbox "${selected.name}" is being rebuilt because its image is no longer available. Run again in a moment.`
354+
}
355+
313356
function assertLanguageMatches(sandbox: ResolvedSandbox, language?: CodeLanguage): void {
314357
if (!language || sandbox.language === language) return
315358
throw new Error(

apps/sim/lib/execution/remote-sandbox/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,17 @@ export interface SandboxImageBuilder {
189189
getBuildStatus(build: SandboxImageBuild, spec: SandboxSpec): Promise<SandboxImageBuildStatus>
190190
/** Removes a built image from the provider. Used by the retention sweep. */
191191
deleteImage(build: SandboxImageBuild): Promise<void>
192+
/**
193+
* Whether a failure from {@link SandboxProvider.create} means the image itself
194+
* is gone, rather than anything else that can go wrong reaching the provider.
195+
*
196+
* Only a `prebuilt` provider can answer this, and it is what makes a dead
197+
* `imageRef` self-correcting: the registry and the provider are two systems with
198+
* no shared transaction, so instead of trying to keep them in step, the create
199+
* that observes the truth reports it. Must stay narrow — treating an auth or
200+
* rate-limit failure as a missing image would rebuild on every outage.
201+
*/
202+
isMissingImage(error: unknown): Promise<boolean>
192203
}
193204

194205
export interface SandboxProvider {

0 commit comments

Comments
 (0)