Skip to content

Commit 1b69b54

Browse files
committed
release lock on client disconnect
1 parent 6d703a4 commit 1b69b54

File tree

4 files changed

+97
-12
lines changed

4 files changed

+97
-12
lines changed

js/tests/timeout.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { expect } from 'vitest'
2+
import { sandboxTest } from './setup'
3+
4+
// Regression test for issue #213: asyncio.Lock not released on client disconnect.
5+
// Uses its own context to avoid blocking other parallel tests with the sleep.
6+
sandboxTest(
7+
'execution after timeout is not blocked',
8+
async ({ sandbox }) => {
9+
const context = await sandbox.createCodeContext()
10+
11+
// sleep(5) with 2s timeout: client disconnects at 2s, kernel finishes at 5s.
12+
await expect(
13+
sandbox.runCode('import time; time.sleep(5)', {
14+
context,
15+
timeoutMs: 2_000,
16+
})
17+
).rejects.toThrow()
18+
19+
// With the fix (lock released after send), this sends immediately and
20+
// succeeds once the kernel finishes the sleep. Without the fix, this
21+
// blocks on the server lock indefinitely.
22+
const result = await sandbox.runCode('x = 1; x', {
23+
context,
24+
timeoutMs: 15_000,
25+
})
26+
expect(result.text).toEqual('1')
27+
},
28+
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from e2b import TimeoutException
2+
from e2b_code_interpreter.code_interpreter_async import AsyncSandbox
3+
4+
import pytest
5+
6+
7+
async def test_execution_after_timeout_is_not_blocked(async_sandbox: AsyncSandbox):
8+
"""After a client-side timeout, subsequent executions should not be blocked
9+
behind an orphaned lock. Regression test for issue #213.
10+
11+
Uses its own context to avoid blocking other parallel tests with the sleep.
12+
"""
13+
14+
context = await async_sandbox.create_code_context()
15+
16+
# sleep(5) with 2s timeout: client disconnects at 2s, kernel finishes at 5s.
17+
with pytest.raises(TimeoutException):
18+
await async_sandbox.run_code(
19+
"import time; time.sleep(5)", context=context, timeout=2
20+
)
21+
22+
# With the fix (lock released after send), this sends immediately and
23+
# succeeds once the kernel finishes the sleep. Without the fix, this
24+
# blocks on the server lock indefinitely.
25+
result = await async_sandbox.run_code("x = 1; x", context=context, timeout=15)
26+
assert result.text == "1"

python/tests/sync/test_timeout.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from e2b import TimeoutException
2+
from e2b_code_interpreter.code_interpreter_sync import Sandbox
3+
4+
import pytest
5+
6+
7+
def test_execution_after_timeout_is_not_blocked(sandbox: Sandbox):
8+
"""After a client-side timeout, subsequent executions should not be blocked
9+
behind an orphaned lock. Regression test for issue #213.
10+
11+
Uses its own context to avoid blocking other parallel tests with the sleep.
12+
"""
13+
14+
context = sandbox.create_code_context()
15+
16+
# sleep(5) with 2s timeout: client disconnects at 2s, kernel finishes at 5s.
17+
with pytest.raises(TimeoutException):
18+
sandbox.run_code("import time; time.sleep(5)", context=context, timeout=2)
19+
20+
# With the fix (lock released after send), this sends immediately and
21+
# succeeds once the kernel finishes the sleep. Without the fix, this
22+
# blocks on the server lock indefinitely.
23+
result = sandbox.run_code("x = 1; x", context=context, timeout=15)
24+
assert result.text == "1"

template/server/messaging.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,14 @@ async def execute(
294294
if self._ws is None:
295295
raise Exception("WebSocket not connected")
296296

297+
message_id = str(uuid.uuid4())
298+
execution = Execution()
299+
300+
# Lock only the setup + send phase, not the streaming phase.
301+
# Results are read from a per-execution queue (keyed by message_id)
302+
# so streaming doesn't need serialization. Releasing before streaming
303+
# prevents client disconnect from holding the lock until the kernel
304+
# finishes execution (see #213).
297305
async with self._lock:
298306
# Wait for any pending cleanup task to complete
299307
if self._cleanup_task and not self._cleanup_task.done():
@@ -327,8 +335,6 @@ async def execute(
327335
)
328336
complete_code = f"{indented_env_code}\n{complete_code}"
329337

330-
message_id = str(uuid.uuid4())
331-
execution = Execution()
332338
self._executions[message_id] = execution
333339

334340
# Send the code for execution
@@ -362,17 +368,19 @@ async def execute(
362368
)
363369
await execution.queue.put(UnexpectedEndOfExecution())
364370

365-
# Stream the results
371+
# Stream the results without holding the lock
372+
try:
366373
async for item in self._wait_for_result(message_id):
367374
yield item
375+
finally:
376+
if message_id in self._executions:
377+
del self._executions[message_id]
368378

369-
del self._executions[message_id]
370-
371-
# Clean up env vars in a separate request after the main code has run
372-
if env_vars:
373-
self._cleanup_task = asyncio.create_task(
374-
self._cleanup_env_vars(env_vars)
375-
)
379+
# Clean up env vars in a separate request after the main code has run
380+
if env_vars:
381+
self._cleanup_task = asyncio.create_task(
382+
self._cleanup_env_vars(env_vars)
383+
)
376384

377385
async def _receive_message(self):
378386
if not self._ws:
@@ -385,8 +393,7 @@ async def _receive_message(self):
385393
except Exception as e:
386394
logger.error(f"WebSocket received error while receiving messages: {str(e)}")
387395
finally:
388-
# To prevent infinite hang, we need to cancel all ongoing execution as we could lost results during the reconnect
389-
# Thanks to the locking, there can be either no ongoing execution or just one.
396+
# To prevent infinite hang, cancel all ongoing executions as results may be lost during reconnect.
390397
for key, execution in self._executions.items():
391398
await execution.queue.put(
392399
Error(

0 commit comments

Comments
 (0)