import asyncio from typing import Any, cast import pytest from e2b.envd.process import process_pb2 from e2b.sandbox_async.commands.command_handle import AsyncCommandHandle from e2b.sandbox_sync.commands.command_handle import CommandHandle EMOJI = "😀" EMOJI_BYTES = EMOJI.encode("utf-8") # 4 bytes def _stdout_event(data: bytes) -> process_pb2.StartResponse: return process_pb2.StartResponse( event=process_pb2.ProcessEvent( data=process_pb2.ProcessEvent.DataEvent(stdout=data) ) ) def _stderr_event(data: bytes) -> process_pb2.StartResponse: return process_pb2.StartResponse( event=process_pb2.ProcessEvent( data=process_pb2.ProcessEvent.DataEvent(stderr=data) ) ) def _end_event(exit_code: int = 0) -> process_pb2.StartResponse: return process_pb2.StartResponse( event=process_pb2.ProcessEvent( end=process_pb2.ProcessEvent.EndEvent( exit_code=exit_code, exited=True, status="exited" ) ) ) async def _kill() -> bool: return True class _AsyncControllableEvents: """Async event source that delivers items on demand. Lets a test hold the handle's event-handling task blocked waiting for the next event (idle between bursts), then push a late event after ``disconnect()`` to confirm it never reaches the callback — the transport condition that triggers the JS SDK leak. """ def __init__(self): self._queue: asyncio.Queue = asyncio.Queue() self._closed = False def __aiter__(self): return self async def __anext__(self): if self._closed and self._queue.empty(): raise StopAsyncIteration item = await self._queue.get() if item is None: raise StopAsyncIteration return item def push(self, event): self._queue.put_nowait(event) async def aclose(self): # Intentionally does NOT unblock a read that is already suspended on # the queue: this models the JS-leak transport condition where the # stream stays open past the close request. The only thing that stops # the handle's event loop here is task cancellation in disconnect(). self._closed = True async def test_async_disconnect_stops_callbacks(): events = _AsyncControllableEvents() chunks = [] handle = AsyncCommandHandle( pid=1, handle_kill=_kill, # The handle only async-iterates and aclose()s the stream; this stand-in # satisfies both without being a real async generator. events=cast(Any, events), on_stdout=chunks.append, ) # First burst is delivered to the live subscriber. events.push(_stdout_event(b"a")) for _ in range(200): if chunks == ["a"]: break await asyncio.sleep(0.005) assert chunks == ["a"] # disconnect() cancels the event-handling task and closes the stream, so # once it returns the callback must not fire again. await handle.disconnect() # A late event (stdout arriving after disconnect) must never reach the # callback. events.push(_stdout_event(b"b")) await asyncio.sleep(0.05) assert chunks == ["a"] def test_sync_has_no_background_subscription(): # The sync handle has no detached event-handling task: events are consumed # only while the caller iterates (e.g. inside wait()), so there is no # subscription that could keep firing after disconnect(). consumed = [] def events(): consumed.append("started") yield _stdout_event(b"a") yield _end_event() handle = CommandHandle(pid=1, handle_kill=lambda: True, events=events()) # Nothing is consumed until the caller iterates. assert consumed == [] # disconnect() just closes the (un-started) stream — still nothing consumed. handle.disconnect() assert consumed == [] def test_sync_records_result_before_yielding_flushed_chunk(): # A consumer that stops iterating right after the end event's flushed chunk # must still observe the exit code: the result is recorded before the # flushed chunk is yielded. def events(): yield _stdout_event(b"a" + EMOJI_BYTES[:2]) yield _end_event(0) handle = CommandHandle(pid=1, handle_kill=lambda: True, events=events()) iterator = iter(handle) assert next(iterator) == ("a", None, None) # The end event flushes a trailing replacement character; pull just that # chunk and then stop iterating. next(iterator) iterator.close() assert handle._result is not None assert handle._result.exit_code == 0 assert handle._result.stdout == "a�" def test_sync_decodes_multibyte_chars_split_across_chunks(): def events(): yield _stdout_event(b"a" + EMOJI_BYTES[:2]) yield _stdout_event(EMOJI_BYTES[2:] + b"b") yield _stderr_event(EMOJI_BYTES[:3]) yield _stderr_event(EMOJI_BYTES[3:]) yield _end_event() chunks = [] handle = CommandHandle(pid=1, handle_kill=lambda: True, events=events()) result = handle.wait(on_stdout=chunks.append) assert result.stdout == f"a{EMOJI}b" assert result.stderr == EMOJI assert "�" not in result.stdout assert "�" not in result.stderr assert "".join(chunks) == f"a{EMOJI}b" def test_sync_replaces_incomplete_trailing_utf8(): def events(): yield _stdout_event(b"a" + EMOJI_BYTES[:2]) yield _end_event() handle = CommandHandle(pid=1, handle_kill=lambda: True, events=events()) result = handle.wait() assert result.stdout == "a�" async def test_async_decodes_multibyte_chars_split_across_chunks(): async def events(): yield _stdout_event(b"a" + EMOJI_BYTES[:2]) yield _stdout_event(EMOJI_BYTES[2:] + b"b") yield _stderr_event(EMOJI_BYTES[:3]) yield _stderr_event(EMOJI_BYTES[3:]) yield _end_event() chunks = [] handle = AsyncCommandHandle( pid=1, handle_kill=_kill, events=events(), on_stdout=chunks.append, ) result = await handle.wait() assert result.stdout == f"a{EMOJI}b" assert result.stderr == EMOJI assert "�" not in result.stdout assert "�" not in result.stderr assert "".join(chunks) == f"a{EMOJI}b" async def test_async_replaces_incomplete_trailing_utf8(): async def events(): yield _stdout_event(b"a" + EMOJI_BYTES[:2]) yield _end_event() handle = AsyncCommandHandle(pid=1, handle_kill=_kill, events=events()) result = await handle.wait() assert result.stdout == "a�" def test_sync_flushes_incomplete_trailing_utf8_without_end_event(): def events(): yield _stdout_event(b"a" + EMOJI_BYTES[:2]) chunks = [] handle = CommandHandle(pid=1, handle_kill=lambda: True, events=events()) for stdout, _, _ in handle: if stdout is not None: chunks.append(stdout) assert "".join(chunks) == "a�" async def test_async_flushes_incomplete_trailing_utf8_without_end_event(): async def events(): yield _stdout_event(b"a" + EMOJI_BYTES[:2]) chunks = [] handle = AsyncCommandHandle( pid=1, handle_kill=_kill, events=events(), on_stdout=chunks.append, ) await handle._wait assert "".join(chunks) == "a�" def test_sync_flushes_incomplete_trailing_utf8_on_stream_error(): def events(): yield _stdout_event(b"a" + EMOJI_BYTES[:2]) raise RuntimeError("stream died") chunks = [] handle = CommandHandle(pid=1, handle_kill=lambda: True, events=events()) # The stream raises before an end event, but the buffered bytes must still # be flushed as a replacement character before the error is surfaced. with pytest.raises(RuntimeError): for stdout, _, _ in handle: if stdout is not None: chunks.append(stdout) assert "".join(chunks) == "a�" async def test_async_flushes_incomplete_trailing_utf8_on_stream_error(): async def events(): yield _stdout_event(b"a" + EMOJI_BYTES[:2]) raise RuntimeError("stream died") chunks = [] handle = AsyncCommandHandle( pid=1, handle_kill=_kill, events=events(), on_stdout=chunks.append, ) await handle._wait # The stream raised before an end event, but the buffered bytes must still # be flushed to the stdout callback as a replacement character. assert "".join(chunks) == "a�" assert isinstance(handle._iteration_exception, RuntimeError)