215 lines
5.5 KiB
Python
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
|