Skip to content

Commit de54b9a

Browse files
committed
fix(sandbox): drop in-flight fetches once the pool gives up or the time budget passes
1 parent ced5556 commit de54b9a

2 files changed

Lines changed: 113 additions & 13 deletions

File tree

‎apps/sim/lib/execution/isolated-vm.ts‎

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,14 @@ interface PendingExecution {
190190
timeout: ReturnType<typeof setTimeout>
191191
ownerKey: string
192192
brokers?: Record<string, IsolatedVMBrokerHandler>
193-
/** Set when the caller aborts. Broker dispatches and the final result stop resolving the promise. */
194-
cancelled: boolean
193+
/**
194+
* Aborted once the pool gives up on the execution (caller abort, timeout, worker loss). Broker
195+
* dispatches and the final result stop resolving the promise, and in-flight `fetch` calls are
196+
* dropped, since their responses can no longer reach the isolate.
197+
*/
198+
abort: AbortController
199+
/** How long the pool waits for a result before giving up; also the longest any one `fetch` may run. */
200+
hostTimeoutMs: number
195201
/** Number of broker calls made so far for this execution. */
196202
brokerCallCount: number
197203
}
@@ -387,7 +393,8 @@ function normalizeFetchOptions(options?: IsolatedFetchOptions): SecureFetchOptio
387393
async function secureFetch(
388394
requestId: string,
389395
url: string,
390-
options?: IsolatedFetchOptions
396+
options: IsolatedFetchOptions | undefined,
397+
signal: AbortSignal
391398
): Promise<string> {
392399
if (url.length > MAX_FETCH_URL_LENGTH) {
393400
return JSON.stringify({
@@ -398,7 +405,7 @@ async function secureFetch(
398405
try {
399406
const response = await secureFetchWithValidation(
400407
url,
401-
normalizeFetchOptions(options),
408+
{ ...normalizeFetchOptions(options), signal },
402409
'fetchUrl'
403410
)
404411
const bodyResult = truncateString(await response.text(), MAX_FETCH_RESPONSE_CHARS)
@@ -758,7 +765,7 @@ function handleBrokerMessage(
758765
return
759766
}
760767

761-
if (pending.cancelled) {
768+
if (pending.abort.signal.aborted) {
762769
sendResponse({ error: 'Execution cancelled' })
763770
return
764771
}
@@ -801,7 +808,7 @@ function handleBrokerMessage(
801808
Promise.resolve()
802809
.then(() => pending.runInOutboundScope(() => handler(args, brokerId)))
803810
.then((resultValue) => {
804-
if (pending.cancelled) {
811+
if (pending.abort.signal.aborted) {
805812
sendResponse({ error: 'Execution cancelled' })
806813
return
807814
}
@@ -864,7 +871,7 @@ function handleWorkerMessage(workerId: number, message: unknown) {
864871
// If the caller aborted, the outer Promise is already resolved with
865872
// AbortError. Still run all the bookkeeping above so pool counters stay
866873
// accurate; just skip re-resolving.
867-
if (!pending.cancelled) {
874+
if (!pending.abort.signal.aborted) {
868875
pending.resolve(msg.result as IsolatedVMExecutionResult)
869876
}
870877
drainQueue()
@@ -895,7 +902,7 @@ function handleWorkerMessage(workerId: number, message: unknown) {
895902
typeof msg.executionId === 'number'
896903
? workerInfo?.pendingExecutions.get(msg.executionId)
897904
: undefined
898-
if (!pending || pending.cancelled) {
905+
if (!pending || pending.abort.signal.aborted) {
899906
sendFetchResponse(JSON.stringify({ error: 'Execution no longer active' }))
900907
return
901908
}
@@ -926,8 +933,14 @@ function handleWorkerMessage(workerId: number, message: unknown) {
926933
return
927934
}
928935
}
936+
// A fetch the script left running after returning is cut off once the execution's own time
937+
// budget has passed: nothing can read its response.
938+
const signal = AbortSignal.any([
939+
pending.abort.signal,
940+
AbortSignal.timeout(pending.hostTimeoutMs),
941+
])
929942
pending
930-
.runInOutboundScope(() => secureFetch(requestId, url, options))
943+
.runInOutboundScope(() => secureFetch(requestId, url, options, signal))
931944
.then(sendFetchResponse)
932945
.catch((err) => {
933946
sendFetchResponse(JSON.stringify({ error: getErrorMessage(err, 'Fetch failed') }))
@@ -971,6 +984,7 @@ function cleanupWorker(workerId: number, interruptedMessage?: string) {
971984

972985
for (const [id, pending] of workerInfo.pendingExecutions) {
973986
clearTimeout(pending.timeout)
987+
pending.abort.abort()
974988
totalActiveExecutions--
975989
const owner = ownerStates.get(pending.ownerKey)
976990
if (owner) {
@@ -1316,8 +1330,11 @@ function dispatchToWorker(
13161330
workerInfo.idleTimeout = null
13171331
}
13181332

1333+
const abort = new AbortController()
1334+
const hostTimeoutMs = req.timeoutMs + 1000
13191335
const timeout = setTimeout(() => {
13201336
workerInfo.pendingExecutions.delete(execId)
1337+
abort.abort()
13211338
// A never-settling promise escapes the isolate's own script timeout, so the worker
13221339
// would otherwise keep the isolate and its timers alive until the worker retires.
13231340
sendCancel(workerInfo, execId)
@@ -1345,15 +1362,16 @@ function dispatchToWorker(
13451362
resetWorkerIdleTimeout(workerInfo.id)
13461363
}
13471364
drainQueue()
1348-
}, req.timeoutMs + 1000)
1365+
}, hostTimeoutMs)
13491366

13501367
workerInfo.pendingExecutions.set(execId, {
13511368
runInOutboundScope: state.runInOutboundScope,
13521369
resolve,
13531370
timeout,
13541371
ownerKey: ownerState.ownerKey,
13551372
brokers,
1356-
cancelled: false,
1373+
abort,
1374+
hostTimeoutMs,
13571375
brokerCallCount: 0,
13581376
})
13591377
workerInfo.activeExecutions++
@@ -1687,13 +1705,13 @@ export async function executeInIsolatedVM(
16871705
if (removed) clearTimeout(removed.queueTimeout)
16881706
state.queueId = undefined
16891707
}
1690-
// If dispatched, mark the pending entry cancelled and ask the worker to
1708+
// If dispatched, abort the pending entry and ask the worker to
16911709
// dispose its isolate so the pool slot can be released. The worker will
16921710
// emit a `result` shortly after, which runs the normal counter cleanup.
16931711
if (state.workerId !== undefined && state.execId !== undefined) {
16941712
const wi = workers.get(state.workerId)
16951713
const pending = wi?.pendingExecutions.get(state.execId)
1696-
if (pending) pending.cancelled = true
1714+
pending?.abort.abort()
16971715
if (wi) sendCancel(wi, state.execId)
16981716
}
16991717
resolveWithRelease({

‎apps/sim/scripts/test-isolated-vm-remote-e2e.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,88 @@ async function run() {
508508
assert.equal(next.result, 'still alive', next.error?.message)
509509
})
510510

511+
/** A server that never answers, so a fetch to it stays in flight until the app drops it. */
512+
const stalledServer = async () => {
513+
const reached = Promise.withResolvers<void>()
514+
const dropped = Promise.withResolvers<void>()
515+
const server = createHttpServer((req) => {
516+
req.socket.once('close', () => dropped.resolve())
517+
reached.resolve()
518+
})
519+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
520+
const address = server.address()
521+
assert(address && typeof address === 'object')
522+
return {
523+
url: `http://127.0.0.1:${address.port}/`,
524+
reached: reached.promise,
525+
/** Whether the app closed the request within `withinMs`. */
526+
droppedSoon: (withinMs = 2000) =>
527+
Promise.race([dropped.promise.then(() => true), sleep(withinMs).then(() => false)]),
528+
close: () => {
529+
server.closeAllConnections()
530+
server.close()
531+
},
532+
}
533+
}
534+
type StalledServer = Awaited<ReturnType<typeof stalledServer>>
535+
536+
const checkFetchDropped = (
537+
name: string,
538+
endExecution: (server: StalledServer) => Promise<void>
539+
) =>
540+
check(name, async () => {
541+
const server = await stalledServer()
542+
try {
543+
await endExecution(server)
544+
assert(await server.droppedSoon(), 'The app kept the fetch open')
545+
} finally {
546+
server.close()
547+
}
548+
})
549+
550+
await check('drops the in-flight fetch of a timed-out execution at the timeout', async () => {
551+
const server = await stalledServer()
552+
try {
553+
// Started late in the run, so only dropping it at the timeout, not at its own time
554+
// budget, closes it promptly.
555+
const result = await execute(
556+
`await fetch('http://127.0.0.1:${slowAddress.port}/?ms=800'); await fetch('${server.url}')`,
557+
{ timeoutMs: 1000 }
558+
)
559+
assert.equal(result.termination, 'timeout', result.error?.message)
560+
assert(await server.droppedSoon(400), 'The timed-out execution kept its fetch open')
561+
} finally {
562+
server.close()
563+
}
564+
})
565+
566+
await checkFetchDropped('drops the in-flight fetch of a cancelled execution', async (server) => {
567+
const controller = new AbortController()
568+
const pending = execute(`await fetch('${server.url}')`, { signal: controller.signal })
569+
await server.reached
570+
controller.abort()
571+
assert.equal((await pending).termination, 'cancelled')
572+
})
573+
574+
await checkFetchDropped('drops the in-flight fetch when its worker dies', async (server) => {
575+
const pending = execute(`await fetch('${server.url}')`)
576+
await server.reached
577+
for (const pid of workerPidsOf(host.process.pid ?? -1)) process.kill(pid, 'SIGKILL')
578+
assert.equal((await pending).error?.isSystemError, true)
579+
})
580+
581+
await checkFetchDropped(
582+
'sends a fetch the script left running, then drops it after the time budget',
583+
async (server) => {
584+
const result = await execute(
585+
`fetch('${server.url}', { method: 'POST', body: 'ping' }); return 1`,
586+
{ timeoutMs: 500 }
587+
)
588+
assert.equal(result.result, 1, result.error?.message)
589+
await server.reached
590+
}
591+
)
592+
511593
const runTask = (code: string) =>
512594
execute(code, {
513595
task: {

0 commit comments

Comments
 (0)