Files
2026-07-13 12:47:58 +08:00

215 lines
5.5 KiB
Python

import asyncio
from packaging.version import Version
from e2b.connection_config import ConnectionConfig
from e2b.envd.filesystem import filesystem_pb2
from e2b.envd.versions import ENVD_DEFAULT_USER
from e2b.sandbox_async.filesystem.watch_handle import AsyncWatchHandle
from e2b.sandbox_sync.filesystem.watch_handle import WatchHandle
def _fs_event(
name: str,
event_type=filesystem_pb2.EventType.EVENT_TYPE_WRITE,
) -> filesystem_pb2.WatchDirResponse:
return filesystem_pb2.WatchDirResponse(
filesystem=filesystem_pb2.FilesystemEvent(name=name, type=event_type)
)
# --- Sync WatchHandle: request timeout + auth header (bug 28) ---
class _FakeSyncRpc:
def __init__(self):
self.calls = []
def get_watcher_events(self, req, **opts):
self.calls.append(("get_watcher_events", req, opts))
return filesystem_pb2.GetWatcherEventsResponse(events=[])
def remove_watcher(self, req, **opts):
self.calls.append(("remove_watcher", req, opts))
return filesystem_pb2.RemoveWatcherResponse()
def _make_sync_handle(rpc, envd_version: Version, user=None) -> WatchHandle:
return WatchHandle(
get_rpc=lambda: rpc,
watcher_id="watcher-1",
connection_config=ConnectionConfig(),
envd_version=envd_version,
user=user,
)
def test_sync_get_new_events_passes_request_timeout_and_auth_header():
rpc = _FakeSyncRpc()
# envd < 0.4.0 has no default user, so the auth header must be sent.
handle = _make_sync_handle(rpc, Version("0.3.0"))
handle.get_new_events()
name, _, opts = rpc.calls[0]
assert name == "get_watcher_events"
# A request timeout is always supplied so a stalled call can't hang forever.
assert opts["request_timeout"] == 60.0
assert opts["headers"].get("Authorization", "").startswith("Basic ")
def test_sync_stop_passes_request_timeout_and_auth_header():
rpc = _FakeSyncRpc()
handle = _make_sync_handle(rpc, Version("0.3.0"))
handle.stop()
name, _, opts = rpc.calls[0]
assert name == "remove_watcher"
assert opts["request_timeout"] == 60.0
assert opts["headers"].get("Authorization", "").startswith("Basic ")
def test_sync_caller_supplied_request_timeout_is_forwarded():
rpc = _FakeSyncRpc()
handle = _make_sync_handle(rpc, ENVD_DEFAULT_USER)
handle.get_new_events(request_timeout=5)
handle.stop(request_timeout=7)
assert rpc.calls[0][2]["request_timeout"] == 5
assert rpc.calls[1][2]["request_timeout"] == 7
# No explicit user on a recent envd → no auth header forced.
assert "Authorization" not in rpc.calls[0][2]["headers"]
# --- Async WatchHandle: on_exit lifecycle (bug 29) ---
async def test_async_on_exit_fires_with_none_on_clean_end():
async def events():
yield _fs_event("a.txt")
received = []
exit_calls = []
handle = AsyncWatchHandle(
events=events(),
on_event=received.append,
on_exit=exit_calls.append,
)
await handle._wait
assert [e.name for e in received] == ["a.txt"]
assert exit_calls == [None]
async def test_async_on_exit_fires_with_error_on_stream_error():
error = RuntimeError("stream died")
async def events():
raise error
yield # pragma: no cover - makes this an async generator
exit_calls = []
handle = AsyncWatchHandle(
events=events(),
on_event=lambda e: None,
on_exit=exit_calls.append,
)
await handle._wait
assert exit_calls == [error]
async def test_async_on_exit_fires_on_stop():
started = asyncio.Event()
async def events():
started.set()
await asyncio.Event().wait() # block until cancelled by stop()
yield # pragma: no cover - never reached
exit_calls = []
handle = AsyncWatchHandle(
events=events(),
on_event=lambda e: None,
on_exit=exit_calls.append,
)
await started.wait()
await handle.stop()
assert exit_calls == [None]
async def test_async_on_exit_awaits_async_callback():
async def events():
yield _fs_event("a.txt")
exit_calls = []
async def on_exit(err):
exit_calls.append(err)
handle = AsyncWatchHandle(
events=events(),
on_event=lambda e: None,
on_exit=on_exit,
)
await handle._wait
assert exit_calls == [None]
async def test_async_on_exit_awaits_async_callback_on_stop():
started = asyncio.Event()
async def events():
started.set()
await asyncio.Event().wait() # block until cancelled by stop()
yield # pragma: no cover - never reached
exit_done = []
async def on_exit(err):
# A real suspension proves the cancellation path drives the async
# callback to completion rather than dropping it mid-await.
await asyncio.sleep(0)
exit_done.append(err)
handle = AsyncWatchHandle(
events=events(),
on_event=lambda e: None,
on_exit=on_exit,
)
await started.wait()
await handle.stop()
assert exit_done == [None]
async def test_async_on_exit_error_does_not_leak():
async def events():
yield _fs_event("a.txt")
async def on_exit(err):
raise RuntimeError("on_exit failed")
handle = AsyncWatchHandle(
events=events(),
on_event=lambda e: None,
on_exit=on_exit,
)
# A raising on_exit must not surface as an unretrieved task exception.
await handle._wait
assert handle._wait.done()
assert handle._wait.exception() is None