diff --git a/src/vercel/sandbox/sandbox.py b/src/vercel/sandbox/sandbox.py index 8203204..9fb5dea 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) + self.sandbox = response.sandbox + if not blocking: + return + 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)) + self.sandbox = response.sandbox + if not blocking: + return + 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..3f2e146 100644 --- a/tests/integration/test_sandbox_sync_async.py +++ b/tests/integration/test_sandbox_sync_async.py @@ -1905,27 +1905,39 @@ class TestSandboxStop: @respx.mock def test_stop_sync(self, mock_env_clear, mock_sandbox_get_response): - """Test synchronous sandbox stop.""" + """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, + } - # Mock get sandbox - respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( - return_value=httpx.Response( - 200, - json={ - "sandbox": mock_sandbox_get_response, - "routes": [], - }, - ) + 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": [], + }, + ), + ] ) # 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( @@ -1938,33 +1950,51 @@ def test_stop_sync(self, mock_env_clear, mock_sandbox_get_response): sandbox.stop() assert route.called + 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.""" + """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, + } - # Mock get sandbox - respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{sandbox_id}").mock( - return_value=httpx.Response( - 200, - json={ - "sandbox": mock_sandbox_get_response, - "routes": [], - }, - ) + 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": [], + }, + ), + ] ) # 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,6 +2007,251 @@ async def test_stop_async(self, mock_env_clear, mock_sandbox_get_response): await sandbox.stop() assert route.called + assert sandbox.status == "stopping" + + await sandbox.refresh() + + assert sandbox.status == "stopped" + assert get_route.call_count == 2 + + 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()