Skip to content

Commit cd53fc6

Browse files
mishushakovclaude
andcommitted
Fix kernel interrupt on client disconnect with FastAPI 0.136.3
Bumping FastAPI 0.111.0 -> 0.136.3 pulls in Starlette 1.2.1, which broke the #213 disconnect->interrupt behavior. Starlette >= 1.0 takes a new StreamingResponse path for ASGI spec_version >= 2.4 (advertised by uvicorn 0.30.1): it no longer runs listen_for_disconnect concurrently and no longer cancels the response body iterator on http.disconnect. The interrupt relied on that cancellation, so an abandoned execution was never interrupted and the next execution blocked behind it and timed out. This was the only failing test in both SDKs on the Renovate bump (#207): - js: tests/interrupt.test.ts > subsequent execution works after client timeout - python: test_async_interrupt.py::test_subsequent_execution_works_after_client_timeout Detect the disconnect explicitly: thread the Request into execute(), and on each keepalive tick poll request.is_disconnected(). When it flips, raise an internal _ClientDisconnected, interrupt the kernel, and stop streaming. The old cancellation path is still handled for older Starlette. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8deea75 commit cd53fc6

4 files changed

Lines changed: 43 additions & 22 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@e2b/code-interpreter-template': patch
3+
---
4+
5+
Interrupt the kernel when the HTTP client disconnects mid-execution so the per-context lock is released and subsequent executions aren't blocked (#213). On the latest FastAPI (0.136.3) / Starlette (1.2.1), `StreamingResponse` no longer cancels the response body iterator on `http.disconnect` (ASGI spec 2.4+), so the server now detects the disconnect itself by polling `request.is_disconnected()` while streaming and interrupts the kernel.

template/server/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ async def post_execute(request: Request, exec_request: ExecutionRequest):
123123
exec_request.code,
124124
env_vars=exec_request.env_vars,
125125
access_token=request.headers.get("X-Access-Token", None),
126+
request=request,
126127
)
127128
)
128129

template/server/messaging.py

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
Union,
1414
)
1515
from pydantic import StrictStr
16+
from starlette.requests import Request
1617
from websockets.client import WebSocketClientProtocol, connect
1718
from websockets.exceptions import (
1819
ConnectionClosedError,
@@ -39,6 +40,10 @@
3940
KEEPALIVE_INTERVAL = 5 # seconds between keepalive pings during streaming
4041

4142

43+
class _ClientDisconnected(Exception):
44+
"""Raised internally when the HTTP client disconnects mid-execution (#213)."""
45+
46+
4247
class Execution:
4348
def __init__(self, in_background: bool = False):
4449
self.queue = Queue[
@@ -251,27 +256,29 @@ async def _cleanup_env_vars(self, env_vars: Dict[StrictStr, str]):
251256
finally:
252257
del self._executions[message_id]
253258

254-
async def _wait_for_result(self, message_id: str):
259+
async def _wait_for_result(
260+
self, message_id: str, request: Optional[Request] = None
261+
):
255262
queue = self._executions[message_id].queue
256263

257-
# Use a timeout on queue.get() to periodically send keepalives.
258-
# Without keepalives, the generator blocks indefinitely waiting for
259-
# kernel output. If the client silently disappears (e.g. network
260-
# failure), uvicorn can only detect the broken connection when it
261-
# tries to write — so we force a write every KEEPALIVE_INTERVAL
262-
# seconds. This ensures timely disconnect detection and kernel
263-
# interrupt for abandoned executions (see #213).
264+
# Wait with a timeout so that, even when the kernel emits no output, we
265+
# periodically poll for client disconnects and write a keepalive. The
266+
# latest Starlette no longer cancels this generator on disconnect, so
267+
# an orphaned execution would otherwise keep holding self._lock (#213).
264268
while True:
265269
try:
266270
output = await asyncio.wait_for(queue.get(), timeout=KEEPALIVE_INTERVAL)
267271
except asyncio.TimeoutError:
268-
# Yield a keepalive so Starlette writes to the socket.
269-
# If the client has disconnected, the write fails and
270-
# uvicorn delivers http.disconnect, which cancels this
271-
# generator via CancelledError.
272+
if request is not None and await request.is_disconnected():
273+
raise _ClientDisconnected()
272274
yield {"type": "keepalive"}
273275
continue
274276

277+
# Also check before forwarding output, in case the client left
278+
# while the kernel was actively streaming.
279+
if request is not None and await request.is_disconnected():
280+
raise _ClientDisconnected()
281+
275282
if output.type == OutputType.END_OF_EXECUTION:
276283
break
277284

@@ -320,6 +327,7 @@ async def execute(
320327
code: Union[str, StrictStr],
321328
env_vars: Dict[StrictStr, str],
322329
access_token: str,
330+
request: Optional[Request] = None,
323331
):
324332
if self._ws is None:
325333
raise Exception("WebSocket not connected")
@@ -368,10 +376,12 @@ async def execute(
368376
logger.info(
369377
f"Sending code for the execution ({message_id}): {complete_code}"
370378
)
371-
request = self._get_execute_request(
379+
# Don't rebind `request`: it holds the Starlette Request
380+
# we poll for disconnects below (#213).
381+
execute_request = self._get_execute_request(
372382
message_id, complete_code, False
373383
)
374-
await self._ws.send(request)
384+
await self._ws.send(execute_request)
375385
break
376386
except (ConnectionClosedError, WebSocketException) as e:
377387
# Keep the last result, even if error
@@ -392,22 +402,27 @@ async def execute(
392402
)
393403
await execution.queue.put(UnexpectedEndOfExecution())
394404

395-
# Stream the results.
396-
# If the client disconnects (Starlette cancels the task), we
397-
# interrupt the kernel so the next execution isn't blocked (#213).
405+
# Stream the results. On client disconnect we interrupt the kernel
406+
# so the lock is released and the next execution isn't blocked
407+
# (#213). The disconnect surfaces either as _ClientDisconnected
408+
# (latest Starlette, raised by _wait_for_result) or as
409+
# CancelledError/GeneratorExit (older Starlette / generator teardown).
398410
try:
399-
async for item in self._wait_for_result(message_id):
411+
async for item in self._wait_for_result(message_id, request=request):
400412
yield item
401-
except (asyncio.CancelledError, GeneratorExit):
413+
except (asyncio.CancelledError, GeneratorExit, _ClientDisconnected) as e:
402414
logger.warning(
403415
f"Client disconnected during execution ({message_id}), interrupting kernel"
404416
)
405-
# Shield the interrupt from the ongoing cancellation so
406-
# the HTTP request to the kernel actually completes.
417+
# Shield so the interrupt completes even if we're being cancelled.
407418
try:
408419
await asyncio.shield(self.interrupt())
409420
except asyncio.CancelledError:
410421
pass
422+
# We detected the disconnect ourselves: unwind cleanly so the
423+
# lock releases. A real cancellation/teardown must propagate.
424+
if isinstance(e, _ClientDisconnected):
425+
return
411426
raise
412427
finally:
413428
if message_id in self._executions:

template/server/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
fastapi==0.111.0
1+
fastapi==0.136.3
22
httpx==0.28.1
33
websockets==12.0
44
uvicorn[standard]==0.30.1

0 commit comments

Comments
 (0)