Skip to content

Commit bd7085e

Browse files
authored
fix(sandbox): keep reused E2B workbench leases within the runtime cap (#8758)
* fix(sandbox): keep reused E2B workbench leases within the runtime cap E2B kills a running sandbox 24h after it last started or resumed and silently clamps any longer timeout. Reconnecting to a workbench late in its life recorded a lease past that cap, so outlives() skipped the extension and a run cut off at the cap was reported as a user timeout. - Track the cap on every handle and never record a session deadline past it - Pause and resume a running workbench whose lease cannot fit before its cap, which resets E2B's runtime count with files and processes intact - Classify a timeout at the cap of a reconnected workbench as provider_limit * fix(sandbox): attribute a timeout to the E2B cap only when the command would outlive it * fix(sandbox): measure the command deadline from E2B dispatch * fix(sandbox): let workbench file reads request their idle lease through the reconnect
1 parent f775b29 commit bd7085e

6 files changed

Lines changed: 230 additions & 27 deletions

File tree

‎apps/sim/lib/execution/remote-sandbox/e2b-session.test.ts‎

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const {
66
create,
77
list,
88
connect,
9+
pause,
910
nextItems,
1011
getInfo,
1112
writeFile,
@@ -22,6 +23,7 @@ const {
2223
create: vi.fn(),
2324
list: vi.fn(),
2425
connect: vi.fn(),
26+
pause: vi.fn(),
2527
nextItems: vi.fn(),
2628
getInfo: vi.fn(),
2729
writeFile: vi.fn(),
@@ -36,22 +38,27 @@ const {
3638
NotFoundError: class NotFoundError extends Error {},
3739
}))
3840
vi.mock('@e2b/code-interpreter', () => ({
39-
Sandbox: { create, list, connect, getInfo },
41+
Sandbox: { create, list, connect, pause, getInfo },
4042
NotFoundError,
4143
}))
4244
vi.mock('@/lib/execution/remote-sandbox/session-lock', () => ({
4345
withSandboxSessionLock: sessionLock,
4446
}))
4547

46-
import { e2bProvider, stopE2BSessionProcess } from '@/lib/execution/remote-sandbox/e2b'
48+
import {
49+
E2B_MAX_SANDBOX_LIFETIME_MS,
50+
e2bProvider,
51+
stopE2BSessionProcess,
52+
} from '@/lib/execution/remote-sandbox/e2b'
4753
import { observeSandboxExecution } from '@/lib/execution/remote-sandbox/execution-observer'
4854

4955
setEnv({ E2B_API_KEY: 'test', MOTHERSHIP_E2B_TEMPLATE_ID: 'mothership-template' })
5056

5157
function candidate(sandboxId: string, time: number) {
5258
return {
5359
sandboxId,
54-
startedAt: new Date(time),
60+
state: 'running',
61+
startedAt: new Date(Date.now() - 3_600_000 + time),
5562
endAt: new Date(Date.now() + 3_600_000),
5663
metadata: { simSessionKey: 'chat', simSessionOwnership: 'tracked-v1' },
5764
}
@@ -681,3 +688,117 @@ describe('E2B session lease', () => {
681688
expect(plane.requests).toBe(3)
682689
})
683690
})
691+
692+
describe('E2B continuous-runtime cap', () => {
693+
const CAP_MS = E2B_MAX_SANDBOX_LIFETIME_MS
694+
const LEASE_MS = 21 * 60_000
695+
696+
/** Models E2B: every timeout is clamped to the cap, and resuming a paused sandbox restarts it. */
697+
function cappedPlane(runningForMs: number) {
698+
const plane = { startedAtMs: Date.now() - runningForMs, endAtMs: 0, paused: false }
699+
const clampedEnd = (timeoutMs: number) =>
700+
Math.min(Date.now() + timeoutMs, plane.startedAtMs + CAP_MS)
701+
plane.endAtMs = clampedEnd(5 * 60_000)
702+
list.mockReturnValue({ nextItems, hasNext: false })
703+
nextItems.mockImplementation(async () => [
704+
{
705+
...candidate('retained', 0),
706+
state: plane.paused ? 'paused' : 'running',
707+
startedAt: new Date(plane.startedAtMs),
708+
endAt: new Date(plane.endAtMs),
709+
},
710+
])
711+
pause.mockImplementation(async () => {
712+
plane.paused = true
713+
return true
714+
})
715+
const sandbox = {
716+
sandboxId: 'retained',
717+
getInfo: async () => ({ endAt: new Date(plane.endAtMs) }),
718+
setTimeout: async (timeoutMs: number) => {
719+
plane.endAtMs = clampedEnd(timeoutMs)
720+
},
721+
commands: {
722+
run: async (_command: string, options: { background?: boolean }) => {
723+
if (options.background === false) {
724+
return { stdout: '{"settled": true}', stderr: '', exitCode: 0 }
725+
}
726+
throw Object.assign(new Error('Sandbox timeout: end of life'), { name: 'TimeoutError' })
727+
},
728+
},
729+
}
730+
connect.mockImplementation(async (_id: string, options: { timeoutMs: number }) => {
731+
if (plane.paused) {
732+
plane.paused = false
733+
plane.startedAtMs = Date.now()
734+
}
735+
plane.endAtMs = clampedEnd(options.timeoutMs)
736+
return sandbox
737+
})
738+
return plane
739+
}
740+
741+
it('never records a reused lease past the cap when the runtime cannot be reset', async () => {
742+
const plane = cappedPlane(CAP_MS - 10 * 60_000)
743+
pause.mockRejectedValueOnce(new Error('pause unavailable'))
744+
const sandbox = await e2bProvider.findSessionSandbox?.('chat', { lifetimeMs: LEASE_MS })
745+
const now = Date.now()
746+
expect(plane.endAtMs).toBeLessThan(now + LEASE_MS)
747+
expect(sandbox?.outlives?.(LEASE_MS, now)).toBe(false)
748+
expect(sandbox?.outlives?.(plane.endAtMs - now - 1000, now)).toBe(true)
749+
await sandbox?.extendLifetime?.(LEASE_MS)
750+
expect(sandbox?.outlives?.(LEASE_MS, Date.now())).toBe(false)
751+
})
752+
753+
it('pauses and resumes a workbench whose lease would outrun the cap', async () => {
754+
const plane = cappedPlane(CAP_MS - 10 * 60_000)
755+
const requestedAtMs = Date.now()
756+
const sandbox = await e2bProvider.findSessionSandbox?.('chat', { lifetimeMs: LEASE_MS })
757+
expect(plane.startedAtMs).toBeGreaterThanOrEqual(requestedAtMs)
758+
expect(plane.endAtMs).toBeGreaterThanOrEqual(requestedAtMs + LEASE_MS)
759+
expect(sandbox?.outlives?.(LEASE_MS, requestedAtMs)).toBe(true)
760+
})
761+
762+
it('reports reaching the cap as the provider limit, not a user timeout', async () => {
763+
cappedPlane(CAP_MS - 30_000)
764+
pause.mockRejectedValueOnce(new Error('pause unavailable'))
765+
const sandbox = await e2bProvider.findSessionSandbox?.('chat', { lifetimeMs: LEASE_MS })
766+
const result = await sandbox?.runCommand('long job', { timeoutMs: LEASE_MS })
767+
expect(result?.providerFailure).toBe('provider_limit')
768+
expect(result?.timedOut).toBeUndefined()
769+
})
770+
771+
it('measures the command deadline from dispatch, after a slow ownership write', async () => {
772+
cappedPlane(CAP_MS - 30_000)
773+
pause.mockRejectedValueOnce(new Error('pause unavailable'))
774+
const sandbox = await e2bProvider.findSessionSandbox?.('chat', { lifetimeMs: LEASE_MS })
775+
if (!sandbox) throw new Error('Missing sandbox')
776+
const realNow = Date.now
777+
let ownershipWriteMs = 0
778+
const clock = vi.spyOn(Date, 'now').mockImplementation(() => realNow() + ownershipWriteMs)
779+
try {
780+
const result = await observeSandboxExecution(
781+
{
782+
hold: vi.fn(),
783+
unsettled: vi.fn(),
784+
claimProcess: async () => {
785+
ownershipWriteMs = 2_000
786+
},
787+
},
788+
() => sandbox.runCommand('long job', { timeoutMs: 29_000 })
789+
)
790+
expect(result.providerFailure).toBe('provider_limit')
791+
} finally {
792+
clock.mockRestore()
793+
}
794+
})
795+
796+
it('keeps a command that reaches its own timeout near the cap a user timeout', async () => {
797+
cappedPlane(CAP_MS - 50_000)
798+
pause.mockRejectedValueOnce(new Error('pause unavailable'))
799+
const sandbox = await e2bProvider.findSessionSandbox?.('chat', { lifetimeMs: LEASE_MS })
800+
const result = await sandbox?.runCommand('short job', { timeoutMs: 1000 })
801+
expect(result?.providerFailure).toBeUndefined()
802+
expect(result?.timedOut).toBe(true)
803+
})
804+
})

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

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,10 @@ const MATERIALIZER_REVISION_RADIX = 1000
106106
/** Public provider alias used by builder/task tests and release tooling. */
107107
export const E2B_SANDBOX_MATERIALIZER_REVISION = FUNCTION_SANDBOX_MATERIALIZER_REVISION
108108

109-
/** Maximum continuous sandbox lifetime supported by E2B. */
109+
/**
110+
* E2B's continuous-runtime cap on the Pro tier: a running sandbox is killed this long after it
111+
* last started or resumed, however long its timeout says, and a pause plus resume resets the count.
112+
*/
110113
export const E2B_MAX_SANDBOX_LIFETIME_MS = 24 * 60 * 60 * 1000
111114

112115
/** E2B sends sandbox lifetimes as whole seconds. */
@@ -164,14 +167,17 @@ function prepareE2BCommand(
164167
}
165168
}
166169

170+
/** Only a command whose own timeout would outlive the cap can have been ended by it. */
167171
function reachedE2BProviderLimit(
168172
error: unknown,
169-
providerLimitAtMs: number | undefined,
173+
providerLimitAtMs: number,
174+
commandDeadlineAtMs: number | undefined,
170175
signal?: AbortSignal
171176
): boolean {
172177
return (
173178
!signal?.aborted &&
174-
providerLimitAtMs !== undefined &&
179+
commandDeadlineAtMs !== undefined &&
180+
commandDeadlineAtMs >= providerLimitAtMs &&
175181
Date.now() >= providerLimitAtMs - E2B_PROVIDER_LIMIT_CLASSIFICATION_WINDOW_MS &&
176182
isE2BExecutionTimeout(error)
177183
)
@@ -358,19 +364,30 @@ export async function stopE2BSessionProcess(
358364
class E2BSandboxHandle implements SandboxHandle {
359365
private killed = false
360366
private killPromise: Promise<void> | null = null
367+
private sessionDeadlineAtMs?: number
361368

362369
/**
370+
* @param providerLimitAtMs No later than E2B's continuous-runtime cap for this sandbox, which no
371+
* timeout request can extend past.
363372
* @param sessionDeadlineAtMs Earliest time the provider can reap this session sandbox, as set
364-
* by this handle's own create, connect, or timeout request. Session deadlines only ever move
365-
* later — every update path extends and none shortens — so it stays a valid lower bound.
373+
* by this handle's own create, connect, or timeout request and never past the cap. Session
374+
* deadlines only ever move later — every update path extends and none shortens — so it stays
375+
* a valid lower bound.
366376
*/
367377
constructor(
368378
private readonly sandbox: E2BSandbox,
369379
private readonly language: CodeLanguage,
370-
private readonly providerLimitAtMs?: number,
380+
private readonly providerLimitAtMs: number,
371381
private readonly sessionKey?: string,
372-
private sessionDeadlineAtMs?: number
373-
) {}
382+
sessionDeadlineAtMs?: number
383+
) {
384+
if (sessionDeadlineAtMs !== undefined) this.recordSessionDeadline(sessionDeadlineAtMs)
385+
}
386+
387+
/** E2B clamps every timeout to the cap, so no granted lease reaches past it. */
388+
private recordSessionDeadline(atMs: number): void {
389+
this.sessionDeadlineAtMs = Math.min(atMs, this.providerLimitAtMs)
390+
}
374391

375392
get sandboxId(): string {
376393
return this.sandbox.sandboxId
@@ -393,7 +410,7 @@ class E2BSandboxHandle implements SandboxHandle {
393410
}
394411
const requestedAtMs = Date.now()
395412
await this.sandbox.setTimeout(timeoutMs)
396-
if (this.sessionKey !== undefined) this.sessionDeadlineAtMs = requestedAtMs + timeoutMs
413+
if (this.sessionKey !== undefined) this.recordSessionDeadline(requestedAtMs + timeoutMs)
397414
}
398415

399416
async runCode(
@@ -484,6 +501,7 @@ class E2BSandboxHandle implements SandboxHandle {
484501
operation: 'code' | 'command'
485502
): Promise<SandboxCommandResult> {
486503
if (this.sessionKey !== undefined) options.signal?.throwIfAborted()
504+
let commandDeadlineAtMs: number | undefined
487505
const outputBudget = new SandboxProcessOutputBudget(
488506
options.maxOutputBytes ?? MAX_SANDBOX_PROCESS_OUTPUT_BYTES
489507
)
@@ -553,6 +571,8 @@ class E2BSandboxHandle implements SandboxHandle {
553571
}
554572
let started: Awaited<ReturnType<E2BSandbox['commands']['run']>>
555573
try {
574+
/** E2B starts the process timeout at dispatch, after the ownership write above. */
575+
commandDeadlineAtMs = Date.now() + processOptions.timeoutMs
556576
started = await this.sandbox.commands.run(
557577
processId
558578
? sessionProcessCommand(processId, prepared.command, options.rootUser)
@@ -662,7 +682,9 @@ class E2BSandboxHandle implements SandboxHandle {
662682
if (outputBudget.error) throw outputBudget.error
663683
if (isSandboxOutputLimitError(error)) throw error
664684
if (isNonRetryableExecutionError(error)) throw error
665-
if (reachedE2BProviderLimit(error, this.providerLimitAtMs, options.signal)) {
685+
if (
686+
reachedE2BProviderLimit(error, this.providerLimitAtMs, commandDeadlineAtMs, options.signal)
687+
) {
666688
recordSandboxProviderLimit({ provider: 'e2b', operation })
667689
return {
668690
stdout: '',
@@ -1085,9 +1107,7 @@ export const e2bProvider: SandboxProvider = {
10851107
return new E2BSandboxHandle(
10861108
sandbox,
10871109
options?.language ?? CodeLanguage.Python,
1088-
effectiveLifetimeMs === E2B_MAX_SANDBOX_LIFETIME_MS
1089-
? lifetimeStartedAtMs + E2B_MAX_SANDBOX_LIFETIME_MS
1090-
: undefined,
1110+
lifetimeStartedAtMs + E2B_MAX_SANDBOX_LIFETIME_MS,
10911111
options?.sessionKey,
10921112
options?.sessionKey && effectiveLifetimeMs !== undefined
10931113
? lifetimeStartedAtMs + effectiveLifetimeMs
@@ -1116,19 +1136,38 @@ export const e2bProvider: SandboxProvider = {
11161136
'This workbench predates durable execution ownership and requires recovery before reuse'
11171137
)
11181138
}
1139+
const leaseMs = options.lifetimeMs === undefined ? 0 : e2bTimeoutMs(options.lifetimeMs)
1140+
let resuming = candidate.state === 'paused'
1141+
// E2B silently clamps any timeout to its continuous-runtime cap. A pause plus resume restarts
1142+
// that count with memory, files, and processes intact, where a new workbench would lose them.
1143+
if (
1144+
!resuming &&
1145+
Date.now() + leaseMs > candidate.startedAt.getTime() + E2B_MAX_SANDBOX_LIFETIME_MS
1146+
) {
1147+
try {
1148+
await Sandbox.pause(candidate.sandboxId, { apiKey })
1149+
resuming = true
1150+
} catch (error) {
1151+
logger.warn(
1152+
'Failed to pause workbench to reset its runtime cap; reusing it until the cap',
1153+
{
1154+
sandboxId: candidate.sandboxId,
1155+
error: getErrorMessage(error),
1156+
}
1157+
)
1158+
}
1159+
}
11191160
// Connect also sets a timeout, including for running sandboxes. Preserve the active deadline,
11201161
// and grant the requested lease in the same request instead of a later getInfo + setTimeout.
11211162
const requestedAtMs = Date.now()
1122-
const timeoutMs = Math.max(
1123-
5 * 60_000,
1124-
candidate.endAt.getTime() - requestedAtMs,
1125-
options.lifetimeMs === undefined ? 0 : e2bTimeoutMs(options.lifetimeMs)
1126-
)
1163+
const timeoutMs = Math.max(5 * 60_000, candidate.endAt.getTime() - requestedAtMs, leaseMs)
1164+
const providerLimitAtMs =
1165+
(resuming ? requestedAtMs : candidate.startedAt.getTime()) + E2B_MAX_SANDBOX_LIFETIME_MS
11271166
const sandbox = await Sandbox.connect(candidate.sandboxId, { apiKey, timeoutMs })
11281167
return new E2BSandboxHandle(
11291168
sandbox,
11301169
options.language ?? CodeLanguage.Python,
1131-
undefined,
1170+
providerLimitAtMs,
11321171
key,
11331172
options.lifetimeMs === undefined ? undefined : requestedAtMs + timeoutMs
11341173
)

‎apps/sim/lib/execution/remote-sandbox/session-file-snapshot.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,29 @@ it('bounds the remote copy itself, not just the eventual response stream', async
9797
).rejects.toThrow('size limit')
9898
expect(mocks.read).not.toHaveBeenCalled()
9999
})
100+
it('asks the reconnect for the idle lease, which can outlast the runtime cap', async () => {
101+
const IDLE_MS = 20 * 60_000
102+
const capAtMs = Date.now() + 60_000
103+
let leaseEndAtMs = capAtMs
104+
const machine = await mocks.find()
105+
mocks.find.mockImplementation(async (_key: string, options: { lifetimeMs?: number }) => {
106+
if (options.lifetimeMs !== undefined) leaseEndAtMs = Date.now() + options.lifetimeMs
107+
return {
108+
...machine,
109+
extendLifetime: async (lifetimeMs: number) => {
110+
leaseEndAtMs = Math.min(Date.now() + lifetimeMs, capAtMs)
111+
},
112+
}
113+
})
114+
const path = join(directory, 'source.txt')
115+
await writeFile(path, 'original')
116+
const snapshot = await openSessionFileSnapshot('chat', path, undefined, undefined, {
117+
allowedRoots: [directory],
118+
maxBytes: 100,
119+
})
120+
await snapshot.dispose()
121+
expect(leaseEndAtMs).toBeGreaterThanOrEqual(Date.now() + IDLE_MS - 1000)
122+
})
100123
it('never creates a replacement machine for a missing read', async () => {
101124
mocks.find.mockResolvedValue(null)
102125
await expect(openSessionFileSnapshot('chat', '/tmp/missing')).rejects.toThrow(

‎apps/sim/lib/execution/remote-sandbox/session-file-snapshot.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ export async function openSessionFileSnapshot(
6161
try {
6262
return await withSandboxSessionLock(sessionKey, signal, async (accessSignal) => {
6363
const provider = resolveProvider()
64-
const sandbox = await provider.findSessionSandbox?.(sessionKey, {})
64+
// The reconnect grants the idle lease itself, so it can reset a workbench near its runtime cap.
65+
const sandbox = await provider.findSessionSandbox?.(sessionKey, {
66+
lifetimeMs: SESSION_SANDBOX_IDLE_MS,
67+
})
6568
accessSignal.throwIfAborted()
6669
if (!sandbox) throw new Error(`No workbench exists for this chat; write "${path}" first.`)
6770
const readStream = sandbox.readFileStream?.bind(sandbox)
@@ -84,7 +87,6 @@ export async function openSessionFileSnapshot(
8487
return disposed
8588
}
8689
try {
87-
await sandbox.extendLifetime?.(SESSION_SANDBOX_IDLE_MS)
8890
accessSignal.throwIfAborted()
8991
const copied = await sandbox.runCommand(COPY_FILE, {
9092
envs: {

‎apps/sim/lib/execution/remote-sandbox/session-files.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,23 @@ describe('workbench file cancellation', () => {
8383
})
8484
})
8585

86+
it('asks the reconnect for the idle lease, which can outlast the runtime cap', async () => {
87+
const IDLE_MS = 20 * 60_000
88+
const capAtMs = Date.now() + 60_000
89+
let leaseEndAtMs = capAtMs
90+
find.mockImplementation(async (_key: string, options: { lifetimeMs?: number }) => {
91+
if (options.lifetimeMs !== undefined) leaseEndAtMs = Date.now() + options.lifetimeMs
92+
return {
93+
readFileWithLimit: read,
94+
extendLifetime: async (lifetimeMs: number) => {
95+
leaseEndAtMs = Math.min(Date.now() + lifetimeMs, capAtMs)
96+
},
97+
}
98+
})
99+
expect(await readSessionSandboxFile('chat', 'input.txt')).toMatchObject({ outcome: 'read' })
100+
expect(leaseEndAtMs).toBeGreaterThanOrEqual(Date.now() + IDLE_MS - 1000)
101+
})
102+
86103
it('does not write if Stop arrives during the sandbox lookup', async () => {
87104
const controller = new AbortController()
88105
find.mockImplementation(async () => {

0 commit comments

Comments
 (0)