From 3064094291a55666fbfcf0f63ae2d615f15ab7d0 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Thu, 19 Mar 2026 20:37:04 -0400 Subject: [PATCH 1/2] Add blocking sandbox stop Extend sync and async sandbox stop with an optional blocking wait path backed by wait_for_status. Add integration and live coverage for blocking stop polling, timeouts, and preserved non-blocking behavior. --- src/vercel/sandbox/sandbox.py | 54 +++- tests/integration/test_sandbox_sync_async.py | 253 ++++++++++++++++++- tests/live/test_sandbox_live.py | 34 +++ 3 files changed, 331 insertions(+), 10 deletions(-) diff --git a/src/vercel/sandbox/sandbox.py b/src/vercel/sandbox/sandbox.py index 8203204..73dd2ac 100644 --- a/src/vercel/sandbox/sandbox.py +++ b/src/vercel/sandbox/sandbox.py @@ -377,8 +377,31 @@ async def write_files(self, files: builtins.list[WriteFile]) -> None: files=files, ) - async def stop(self) -> None: - await self.client.stop_sandbox(sandbox_id=self.sandbox.id) + async def stop( + self, + *, + blocking: bool = False, + timeout: float = 30.0, + poll_interval: float = 0.5, + ) -> None: + """Stop this sandbox. + + Args: + blocking: When ``True``, wait until the sandbox reaches + ``"stopped"`` before returning. + timeout: Maximum time to wait in seconds when ``blocking=True``. + poll_interval: Time between refreshes in seconds when + ``blocking=True``. + + Raises: + TimeoutError: If ``blocking=True`` and the sandbox does not reach + ``"stopped"`` within *timeout*. + """ + response = await self.client.stop_sandbox(sandbox_id=self.sandbox.id) + if not blocking: + return + self.sandbox = response.sandbox + await self.wait_for_status("stopped", timeout=timeout, poll_interval=poll_interval) async def extend_timeout(self, duration: int) -> None: """ @@ -712,8 +735,31 @@ def write_files(self, files: builtins.list[WriteFile]) -> None: ) ) - def stop(self) -> None: - iter_coroutine(self.client.stop_sandbox(sandbox_id=self.sandbox.id)) + def stop( + self, + *, + blocking: bool = False, + timeout: float = 30.0, + poll_interval: float = 0.5, + ) -> None: + """Stop this sandbox. + + Args: + blocking: When ``True``, wait until the sandbox reaches + ``"stopped"`` before returning. + timeout: Maximum time to wait in seconds when ``blocking=True``. + poll_interval: Time between refreshes in seconds when + ``blocking=True``. + + Raises: + TimeoutError: If ``blocking=True`` and the sandbox does not reach + ``"stopped"`` within *timeout*. + """ + response = iter_coroutine(self.client.stop_sandbox(sandbox_id=self.sandbox.id)) + if not blocking: + return + self.sandbox = response.sandbox + self.wait_for_status("stopped", timeout=timeout, poll_interval=poll_interval) def extend_timeout(self, duration: int) -> None: """ diff --git a/tests/integration/test_sandbox_sync_async.py b/tests/integration/test_sandbox_sync_async.py index 15e429e..37f5ee8 100644 --- a/tests/integration/test_sandbox_sync_async.py +++ b/tests/integration/test_sandbox_sync_async.py @@ -1905,13 +1905,12 @@ class TestSandboxStop: @respx.mock def test_stop_sync(self, mock_env_clear, mock_sandbox_get_response): - """Test synchronous sandbox stop.""" + """Test synchronous sandbox stop remains non-blocking by default.""" from vercel.sandbox import Sandbox sandbox_id = "sbx_test123456" - # Mock get sandbox - respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( + get_route = respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( return_value=httpx.Response( 200, json={ @@ -1938,19 +1937,20 @@ def test_stop_sync(self, mock_env_clear, mock_sandbox_get_response): sandbox.stop() assert route.called + assert sandbox.status == "running" + assert get_route.call_count == 1 sandbox.client.close() @respx.mock @pytest.mark.asyncio async def test_stop_async(self, mock_env_clear, mock_sandbox_get_response): - """Test asynchronous sandbox stop.""" + """Test asynchronous sandbox stop remains non-blocking by default.""" from vercel.sandbox import AsyncSandbox sandbox_id = "sbx_test123456" - # Mock get sandbox - respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( + get_route = respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( return_value=httpx.Response( 200, json={ @@ -1977,6 +1977,247 @@ async def test_stop_async(self, mock_env_clear, mock_sandbox_get_response): await sandbox.stop() assert route.called + assert sandbox.status == "running" + assert get_route.call_count == 1 + + await sandbox.client.aclose() + + @respx.mock + def test_stop_sync_blocking_polls_until_stopped( + self, mock_env_clear, mock_sandbox_get_response + ): + """Test blocking sync stop polls until the sandbox is stopped.""" + from vercel.sandbox import Sandbox + + sandbox_id = "sbx_test123456" + stopping_response = {**mock_sandbox_get_response, "status": "stopping"} + stopped_response = { + **mock_sandbox_get_response, + "status": "stopped", + "stoppedAt": mock_sandbox_get_response["updatedAt"] + 1, + } + + get_route = respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( + side_effect=[ + httpx.Response(200, json={"sandbox": mock_sandbox_get_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopping_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopped_response, "routes": []}), + ] + ) + stop_route = respx.post(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}/stop").mock( + return_value=httpx.Response(200, json={"sandbox": stopping_response}) + ) + + sandbox = Sandbox.get( + sandbox_id=sandbox_id, + token="test_token", + team_id="team_test123", + project_id="prj_test123", + ) + + sandbox.stop(blocking=True, poll_interval=0.01) + + assert stop_route.called + assert sandbox.status == "stopped" + assert get_route.call_count == 3 + + sandbox.client.close() + + @respx.mock + def test_stop_sync_blocking_timeout(self, mock_env_clear, mock_sandbox_get_response): + """Test blocking sync stop raises TimeoutError when stop never completes.""" + from vercel.sandbox import Sandbox + + sandbox_id = "sbx_test123456" + stopping_response = {**mock_sandbox_get_response, "status": "stopping"} + + respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( + side_effect=[ + httpx.Response(200, json={"sandbox": mock_sandbox_get_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopping_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopping_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopping_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopping_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopping_response, "routes": []}), + ] + ) + stop_route = respx.post(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}/stop").mock( + return_value=httpx.Response(200, json={"sandbox": stopping_response}) + ) + + sandbox = Sandbox.get( + sandbox_id=sandbox_id, + token="test_token", + team_id="team_test123", + project_id="prj_test123", + ) + + with pytest.raises(TimeoutError, match="did not reach 'stopped' status"): + sandbox.stop(blocking=True, timeout=0.05, poll_interval=0.01) + + assert stop_route.called + assert sandbox.status == "stopping" + + sandbox.client.close() + + @respx.mock + def test_stop_sync_blocking_stops_after_first_stopped_refresh( + self, mock_env_clear, mock_sandbox_get_response + ): + """Test blocking sync stop exits after the first stopped refresh.""" + from vercel.sandbox import Sandbox + + sandbox_id = "sbx_test123456" + stopping_response = {**mock_sandbox_get_response, "status": "stopping"} + stopped_response = { + **mock_sandbox_get_response, + "status": "stopped", + "stoppedAt": mock_sandbox_get_response["updatedAt"] + 1, + } + + get_route = respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( + side_effect=[ + httpx.Response(200, json={"sandbox": mock_sandbox_get_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopped_response, "routes": []}), + ] + ) + stop_route = respx.post(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}/stop").mock( + return_value=httpx.Response(200, json={"sandbox": stopping_response}) + ) + + sandbox = Sandbox.get( + sandbox_id=sandbox_id, + token="test_token", + team_id="team_test123", + project_id="prj_test123", + ) + + sandbox.stop(blocking=True, poll_interval=0.01) + + assert stop_route.called + assert sandbox.status == "stopped" + assert get_route.call_count == 2 + + sandbox.client.close() + + @respx.mock + @pytest.mark.asyncio + async def test_stop_async_blocking_polls_until_stopped( + self, mock_env_clear, mock_sandbox_get_response + ): + """Test blocking async stop polls until the sandbox is stopped.""" + from vercel.sandbox import AsyncSandbox + + sandbox_id = "sbx_test123456" + stopping_response = {**mock_sandbox_get_response, "status": "stopping"} + stopped_response = { + **mock_sandbox_get_response, + "status": "stopped", + "stoppedAt": mock_sandbox_get_response["updatedAt"] + 1, + } + + get_route = respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( + side_effect=[ + httpx.Response(200, json={"sandbox": mock_sandbox_get_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopping_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopped_response, "routes": []}), + ] + ) + stop_route = respx.post(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}/stop").mock( + return_value=httpx.Response(200, json={"sandbox": stopping_response}) + ) + + sandbox = await AsyncSandbox.get( + sandbox_id=sandbox_id, + token="test_token", + team_id="team_test123", + project_id="prj_test123", + ) + + await sandbox.stop(blocking=True, poll_interval=0.01) + + assert stop_route.called + assert sandbox.status == "stopped" + assert get_route.call_count == 3 + + await sandbox.client.aclose() + + @respx.mock + @pytest.mark.asyncio + async def test_stop_async_blocking_timeout(self, mock_env_clear, mock_sandbox_get_response): + """Test blocking async stop raises TimeoutError when stop never completes.""" + from vercel.sandbox import AsyncSandbox + + sandbox_id = "sbx_test123456" + stopping_response = {**mock_sandbox_get_response, "status": "stopping"} + + respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( + side_effect=[ + httpx.Response(200, json={"sandbox": mock_sandbox_get_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopping_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopping_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopping_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopping_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopping_response, "routes": []}), + ] + ) + stop_route = respx.post(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}/stop").mock( + return_value=httpx.Response(200, json={"sandbox": stopping_response}) + ) + + sandbox = await AsyncSandbox.get( + sandbox_id=sandbox_id, + token="test_token", + team_id="team_test123", + project_id="prj_test123", + ) + + with pytest.raises(TimeoutError, match="did not reach 'stopped' status"): + await sandbox.stop(blocking=True, timeout=0.05, poll_interval=0.01) + + assert stop_route.called + assert sandbox.status == "stopping" + + await sandbox.client.aclose() + + @respx.mock + @pytest.mark.asyncio + async def test_stop_async_blocking_stops_after_first_stopped_refresh( + self, mock_env_clear, mock_sandbox_get_response + ): + """Test blocking async stop exits after the first stopped refresh.""" + from vercel.sandbox import AsyncSandbox + + sandbox_id = "sbx_test123456" + stopping_response = {**mock_sandbox_get_response, "status": "stopping"} + stopped_response = { + **mock_sandbox_get_response, + "status": "stopped", + "stoppedAt": mock_sandbox_get_response["updatedAt"] + 1, + } + + get_route = respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( + side_effect=[ + httpx.Response(200, json={"sandbox": mock_sandbox_get_response, "routes": []}), + httpx.Response(200, json={"sandbox": stopped_response, "routes": []}), + ] + ) + stop_route = respx.post(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}/stop").mock( + return_value=httpx.Response(200, json={"sandbox": stopping_response}) + ) + + sandbox = await AsyncSandbox.get( + sandbox_id=sandbox_id, + token="test_token", + team_id="team_test123", + project_id="prj_test123", + ) + + await sandbox.stop(blocking=True, poll_interval=0.01) + + assert stop_route.called + assert sandbox.status == "stopped" + assert get_route.call_count == 2 await sandbox.client.aclose() diff --git a/tests/live/test_sandbox_live.py b/tests/live/test_sandbox_live.py index 9c22fc6..010c078 100644 --- a/tests/live/test_sandbox_live.py +++ b/tests/live/test_sandbox_live.py @@ -283,3 +283,37 @@ def test_mk_dir(self, vercel_token, vercel_team_id, cleanup_registry): # Sandbox may already be stopped or unreachable pass sandbox.client.close() + + def test_blocking_stop_lifecycle(self, vercel_token, vercel_team_id, cleanup_registry): + """Test stop(blocking=True) waits until a real sandbox is stopped.""" + from vercel.sandbox import Sandbox + + sandbox = Sandbox.create( + token=vercel_token, + team_id=vercel_team_id, + ) + cleanup_registry.register("sandbox", sandbox.sandbox_id) + + try: + sandbox.wait_for_status("running") + + sandbox.stop(blocking=True) + + assert sandbox.status == "stopped" + + refreshed = Sandbox.get( + sandbox_id=sandbox.sandbox_id, + token=vercel_token, + team_id=vercel_team_id, + ) + try: + assert refreshed.status == "stopped" + finally: + refreshed.client.close() + finally: + try: + sandbox.stop() + except Exception: + # Sandbox may already be stopped or unreachable + pass + sandbox.client.close() From b2ba59af6b036174f0fdc55bb682d37e31fd2017 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Fri, 27 Mar 2026 11:37:02 -0700 Subject: [PATCH 2/2] Update the sandbox when stopping Even when not blocking, we should update the sandbox state from the response we get back from the server. --- src/vercel/sandbox/sandbox.py | 4 +- tests/integration/test_sandbox_sync_async.py | 86 ++++++++++++++------ 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/src/vercel/sandbox/sandbox.py b/src/vercel/sandbox/sandbox.py index 73dd2ac..9fb5dea 100644 --- a/src/vercel/sandbox/sandbox.py +++ b/src/vercel/sandbox/sandbox.py @@ -398,9 +398,9 @@ async def stop( ``"stopped"`` within *timeout*. """ response = await self.client.stop_sandbox(sandbox_id=self.sandbox.id) + self.sandbox = response.sandbox if not blocking: return - self.sandbox = response.sandbox await self.wait_for_status("stopped", timeout=timeout, poll_interval=poll_interval) async def extend_timeout(self, duration: int) -> None: @@ -756,9 +756,9 @@ def stop( ``"stopped"`` within *timeout*. """ response = iter_coroutine(self.client.stop_sandbox(sandbox_id=self.sandbox.id)) + self.sandbox = response.sandbox if not blocking: return - self.sandbox = response.sandbox self.wait_for_status("stopped", timeout=timeout, poll_interval=poll_interval) def extend_timeout(self, duration: int) -> None: diff --git a/tests/integration/test_sandbox_sync_async.py b/tests/integration/test_sandbox_sync_async.py index 37f5ee8..3f2e146 100644 --- a/tests/integration/test_sandbox_sync_async.py +++ b/tests/integration/test_sandbox_sync_async.py @@ -1905,26 +1905,39 @@ class TestSandboxStop: @respx.mock def test_stop_sync(self, mock_env_clear, mock_sandbox_get_response): - """Test synchronous sandbox stop remains non-blocking by default.""" + """Test synchronous sandbox stop exposes the stop lifecycle without blocking.""" from vercel.sandbox import Sandbox sandbox_id = "sbx_test123456" + stopping_response = {**mock_sandbox_get_response, "status": "stopping"} + stopped_response = { + **mock_sandbox_get_response, + "status": "stopped", + "stoppedAt": mock_sandbox_get_response["updatedAt"] + 1, + } get_route = respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( - return_value=httpx.Response( - 200, - json={ - "sandbox": mock_sandbox_get_response, - "routes": [], - }, - ) + side_effect=[ + httpx.Response( + 200, + json={ + "sandbox": mock_sandbox_get_response, + "routes": [], + }, + ), + httpx.Response( + 200, + json={ + "sandbox": stopped_response, + "routes": [], + }, + ), + ] ) # Mock stop - stopped_response = dict(mock_sandbox_get_response) - stopped_response["status"] = "stopped" route = respx.post(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}/stop").mock( - return_value=httpx.Response(200, json={"sandbox": stopped_response}) + return_value=httpx.Response(200, json={"sandbox": stopping_response}) ) sandbox = Sandbox.get( @@ -1937,34 +1950,51 @@ def test_stop_sync(self, mock_env_clear, mock_sandbox_get_response): sandbox.stop() assert route.called - assert sandbox.status == "running" - assert get_route.call_count == 1 + assert sandbox.status == "stopping" + + sandbox.refresh() + + assert sandbox.status == "stopped" + assert get_route.call_count == 2 sandbox.client.close() @respx.mock @pytest.mark.asyncio async def test_stop_async(self, mock_env_clear, mock_sandbox_get_response): - """Test asynchronous sandbox stop remains non-blocking by default.""" + """Test asynchronous sandbox stop exposes the stop lifecycle without blocking.""" from vercel.sandbox import AsyncSandbox sandbox_id = "sbx_test123456" + stopping_response = {**mock_sandbox_get_response, "status": "stopping"} + stopped_response = { + **mock_sandbox_get_response, + "status": "stopped", + "stoppedAt": mock_sandbox_get_response["updatedAt"] + 1, + } get_route = respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( - return_value=httpx.Response( - 200, - json={ - "sandbox": mock_sandbox_get_response, - "routes": [], - }, - ) + side_effect=[ + httpx.Response( + 200, + json={ + "sandbox": mock_sandbox_get_response, + "routes": [], + }, + ), + httpx.Response( + 200, + json={ + "sandbox": stopped_response, + "routes": [], + }, + ), + ] ) # Mock stop - stopped_response = dict(mock_sandbox_get_response) - stopped_response["status"] = "stopped" route = respx.post(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}/stop").mock( - return_value=httpx.Response(200, json={"sandbox": stopped_response}) + return_value=httpx.Response(200, json={"sandbox": stopping_response}) ) sandbox = await AsyncSandbox.get( @@ -1977,8 +2007,12 @@ async def test_stop_async(self, mock_env_clear, mock_sandbox_get_response): await sandbox.stop() assert route.called - assert sandbox.status == "running" - assert get_route.call_count == 1 + assert sandbox.status == "stopping" + + await sandbox.refresh() + + assert sandbox.status == "stopped" + assert get_route.call_count == 2 await sandbox.client.aclose()