Skip to content

Commit fa3d4d6

Browse files
committed
Center sandbox lifecycle on handles
1 parent 3ae0b47 commit fa3d4d6

13 files changed

Lines changed: 803 additions & 243 deletions

File tree

examples/unstable/sandbox_01_async_code_review.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ async def review_code(
4242
github_token: str | None = None,
4343
) -> str:
4444
# `async with sandbox.create_sandbox(...)` gives you automatic cleanup:
45-
# leaving the block destroys the sandbox, even if the workflow raises.
45+
# leaving the block stops and destroys the sandbox, even if the workflow
46+
# raises.
4647
#
4748
# If you want a persistent sandbox instead, use:
4849
#
@@ -56,8 +57,8 @@ async def review_code(
5657
) as box:
5758
# The sandbox returned by `create_sandbox` already has a current runtime
5859
# session. Most workflows can call commands on `box` and filesystem
59-
# methods on `box.fs`; creating an explicit `box.session()` is an advanced
60-
# operation for separate runtime-session lifecycles.
60+
# methods on `box.fs`. Use `sandbox.resume_sandbox(...)` when reopening
61+
# a stopped persistent sandbox.
6162
await box.fs.mkdir("workspace")
6263
async with box.fs.batch() as batch:
6364
for path, content in files:

examples/unstable/sandbox_02_sync_script_runner.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ def run_script(input_text: str, script: str) -> str:
1515
name = f"vercel-py-script-{uuid4().hex[:12]}"
1616

1717
# The sync API mirrors the async API for scripts and CLIs that do not run an
18-
# event loop. Using the sandbox as a context manager destroys it on exit.
18+
# event loop. Using the sandbox as a context manager stops and destroys it
19+
# on exit.
1920
#
2021
# To keep a sandbox around across calls, create it without `with`:
2122
#

examples/unstable/sandbox_04_dev_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ async def get_or_create_sandbox(
6464
port: int,
6565
) -> Sandbox:
6666
try:
67-
box = await sandbox.get_sandbox(name=name)
67+
box = await sandbox.resume_sandbox(name=name)
6868
print(f"resumed sandbox {box.name}")
6969
return box
7070
except SandboxApiError as error:

src/vercel/_internal/unstable/sandbox/api_client.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def _validate_network_policy(cls, value: object) -> NetworkPolicy | None:
187187

188188
class _GetSandboxRequest(_ApiRequestModel):
189189
project_id: str = Field(serialization_alias="projectId")
190-
resume: bool = True
190+
resume: bool = False
191191
include_system_routes: bool | None = Field(
192192
default=None, serialization_alias="__includeSystemRoutes"
193193
)
@@ -948,7 +948,7 @@ async def get_sandbox(
948948
*,
949949
name: str,
950950
project_id: str | None = None,
951-
resume: bool = True,
951+
resume: bool = False,
952952
include_system_routes: bool | None = None,
953953
) -> SandboxState:
954954
credentials = await self._credentials_factory()
@@ -1065,18 +1065,17 @@ async def update_sandbox(
10651065
sparse_attachments=True,
10661066
)
10671067

1068-
async def create_runtime_session(
1068+
async def resume_sandbox(
10691069
self,
10701070
*,
10711071
name: str,
10721072
project_id: str | None = None,
1073-
resume: bool = True,
10741073
include_system_routes: bool | None = None,
10751074
) -> SandboxState:
10761075
return await self.get_sandbox(
10771076
name=name,
10781077
project_id=project_id,
1079-
resume=resume,
1078+
resume=True,
10801079
include_system_routes=include_system_routes,
10811080
)
10821081

src/vercel/_internal/unstable/sandbox/async_runtime.py

Lines changed: 102 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -709,9 +709,9 @@ class Sandbox(SandboxHandleBase[SandboxRuntimeSession]):
709709
"""Control an asynchronous Vercel Sandbox.
710710
711711
A sandbox has at most one active current session. Process and filesystem
712-
operations target the session recorded by this handle. Use ``session`` to
713-
resolve that session, resuming the sandbox into a replacement session when
714-
needed, and ``destroy`` to permanently remove the sandbox.
712+
operations target the session recorded by this handle. Use
713+
``sandbox.resume_sandbox`` to ensure the sandbox has an active session,
714+
``stop`` to stop it, and ``destroy`` to permanently remove the sandbox.
715715
"""
716716

717717
__slots__ = ("_service", "fs")
@@ -728,25 +728,6 @@ def __init__(self, *, payload: SandboxState, service: SandboxService) -> None:
728728
write_files_cwd=self._write_files_cwd,
729729
)
730730

731-
def session(self) -> "CreateRuntimeSessionOperation":
732-
"""Prepare a single-use current-session operation.
733-
734-
Awaiting returns the sandbox's active current session. If the current
735-
session is stopped or otherwise unusable, the backend resumes the
736-
sandbox from its latest snapshot and returns the replacement session.
737-
Using the operation as an async context manager stops the returned
738-
session on exit.
739-
740-
The returned session handle is independent. If resuming replaces the
741-
backend's current session, this existing ``Sandbox`` handle is not
742-
refreshed automatically.
743-
"""
744-
return CreateRuntimeSessionOperation(
745-
service=self._service,
746-
sandbox_name=self.name,
747-
project_id=self.project_id,
748-
)
749-
750731
async def run_process(
751732
self,
752733
command: str,
@@ -901,6 +882,12 @@ async def snapshot(self, *, expiration: SnapshotExpirationInput = None) -> Snaps
901882
self._apply_current_session_payload(result.session)
902883
return Snapshot(payload=result.snapshot, service=self._service)
903884

885+
async def stop(self) -> Self:
886+
"""Stop the current session and return this sandbox handle."""
887+
payload = await self._service.stop_runtime_session(session_id=self.current_session_id)
888+
self._apply_current_session_payload(payload)
889+
return self
890+
904891
async def destroy(self) -> Self:
905892
"""Permanently destroy the sandbox and refresh this handle."""
906893
payload = await self._service.destroy_sandbox(name=self.name, project_id=self.project_id)
@@ -974,14 +961,21 @@ class CreateSandboxOperation:
974961
"""Manage one asynchronous sandbox creation request.
975962
976963
Await the operation to create a sandbox that remains alive, or use it as an
977-
async context manager to destroy the created sandbox on exit. An operation
978-
can be consumed only once. Exiting the context raises
979-
``SandboxCleanupError`` if destroying the sandbox fails.
964+
async context manager to stop the created sandbox and optionally destroy it
965+
on exit. An operation can be consumed only once. Exiting the context raises
966+
``SandboxCleanupError`` if cleanup fails.
980967
"""
981968

982-
def __init__(self, *, service: SandboxService, params: _CreateSandboxParams) -> None:
969+
def __init__(
970+
self,
971+
*,
972+
service: SandboxService,
973+
params: _CreateSandboxParams,
974+
destroy: bool,
975+
) -> None:
983976
self._service = service
984977
self._params = params
978+
self._destroy = destroy
985979
self._consumed = False
986980
self._handle: Sandbox | None = None
987981

@@ -1025,18 +1019,7 @@ async def __aexit__(
10251019
) -> None:
10261020
if self._handle is None:
10271021
return None
1028-
try:
1029-
payload = await self._service.destroy_sandbox(
1030-
name=self._handle.name, project_id=self._handle.project_id
1031-
)
1032-
self._handle._apply_payload(payload)
1033-
except Exception as cleanup_exc:
1034-
raise SandboxCleanupError(
1035-
f"Failed to clean up sandbox {self._handle.name!r}",
1036-
resource_type="sandbox",
1037-
resource_id=self._handle.name,
1038-
cause=cleanup_exc,
1039-
) from cleanup_exc
1022+
await _cleanup_managed_sandbox(self._handle, destroy=self._destroy)
10401023
return None
10411024

10421025
def __del__(self) -> None:
@@ -1049,41 +1032,46 @@ def __del__(self) -> None:
10491032
)
10501033

10511034

1052-
class CreateRuntimeSessionOperation:
1053-
"""Manage one asynchronous current-session request.
1035+
@dataclass(frozen=True, slots=True)
1036+
class _ResumeSandboxParams:
1037+
name: str
1038+
project_id: str | None = None
1039+
include_system_routes: bool | None = None
1040+
10541041

1055-
Await the operation to return the active current session, resuming the
1056-
sandbox into a replacement session when necessary. Use it as an async
1057-
context manager to stop the returned session on exit. An operation can be
1058-
consumed only once. Exiting the context raises ``SandboxCleanupError`` if
1059-
stopping the session fails.
1042+
class ResumeSandboxOperation:
1043+
"""Manage one asynchronous sandbox resume request.
1044+
1045+
Await the operation to return a sandbox with an active current session, or
1046+
use it as an async context manager to stop that session on exit. An
1047+
operation can be consumed only once. Exiting the context raises
1048+
``SandboxCleanupError`` if stopping the sandbox fails.
10601049
"""
10611050

1062-
def __init__(
1063-
self, *, service: SandboxService, sandbox_name: str, project_id: str | None
1064-
) -> None:
1051+
def __init__(self, *, service: SandboxService, params: _ResumeSandboxParams) -> None:
10651052
self._service = service
1066-
self._sandbox_name = sandbox_name
1067-
self._project_id = project_id
1053+
self._params = params
10681054
self._consumed = False
1069-
self._handle: SandboxRuntimeSession | None = None
1055+
self._handle: Sandbox | None = None
10701056

10711057
def _mark_consumed(self) -> None:
10721058
if self._consumed:
1073-
raise RuntimeError("sandbox runtime-session operations can only be used once")
1059+
raise RuntimeError("sandbox.resume_sandbox(...) operations can only be used once")
10741060
self._consumed = True
10751061

1076-
async def _run_once(self) -> SandboxRuntimeSession:
1062+
async def _run_once(self) -> Sandbox:
10771063
self._mark_consumed()
1078-
payload = await self._service.create_runtime_session(
1079-
name=self._sandbox_name, project_id=self._project_id
1064+
return await resume_sandbox(
1065+
self._service,
1066+
name=self._params.name,
1067+
project_id=self._params.project_id,
1068+
include_system_routes=self._params.include_system_routes,
10801069
)
1081-
return SandboxRuntimeSession(payload=payload, service=self._service)
10821070

1083-
def __await__(self) -> Generator[Any, None, SandboxRuntimeSession]:
1071+
def __await__(self) -> Generator[Any, None, Sandbox]:
10841072
return self._run_once().__await__()
10851073

1086-
async def __aenter__(self) -> SandboxRuntimeSession:
1074+
async def __aenter__(self) -> Sandbox:
10871075
handle = await self._run_once()
10881076
self._handle = handle
10891077
return handle
@@ -1096,18 +1084,41 @@ async def __aexit__(
10961084
) -> None:
10971085
if self._handle is None:
10981086
return None
1099-
try:
1100-
payload = await self._service.stop_runtime_session(session_id=self._handle.id)
1101-
self._handle._apply_payload(payload)
1102-
except Exception as cleanup_exc:
1103-
raise SandboxCleanupError(
1104-
f"Failed to clean up sandbox runtime session {self._handle.id!r}",
1105-
resource_type="sandbox_runtime_session",
1106-
resource_id=self._handle.id,
1107-
cause=cleanup_exc,
1108-
) from cleanup_exc
1087+
await _cleanup_managed_sandbox(self._handle, destroy=False)
11091088
return None
11101089

1090+
def __del__(self) -> None:
1091+
if self._consumed:
1092+
return
1093+
warnings.warn(
1094+
"sandbox.resume_sandbox(...) operation was never awaited or entered",
1095+
RuntimeWarning,
1096+
stacklevel=2,
1097+
)
1098+
1099+
1100+
async def _cleanup_managed_sandbox(handle: Sandbox, *, destroy: bool) -> None:
1101+
cleanup_error: Exception | None = None
1102+
try:
1103+
await handle.stop()
1104+
except Exception as exc:
1105+
cleanup_error = exc
1106+
1107+
if destroy:
1108+
try:
1109+
await handle.destroy()
1110+
except Exception as exc:
1111+
if cleanup_error is None:
1112+
cleanup_error = exc
1113+
1114+
if cleanup_error is not None:
1115+
raise SandboxCleanupError(
1116+
f"Failed to clean up sandbox {handle.name!r}",
1117+
resource_type="sandbox",
1118+
resource_id=handle.name,
1119+
cause=cleanup_error,
1120+
) from cleanup_error
1121+
11111122

11121123
async def _create_sandbox(service: SandboxService, **kwargs: Any) -> Sandbox:
11131124
try:
@@ -1132,6 +1143,7 @@ def create_sandbox_operation(
11321143
tags: Mapping[str, str] | None = None,
11331144
snapshot_expiration: SnapshotExpirationInput = None,
11341145
snapshot_retention: SnapshotRetention | None = None,
1146+
destroy: bool = True,
11351147
) -> CreateSandboxOperation:
11361148
return CreateSandboxOperation(
11371149
service=service,
@@ -1150,13 +1162,35 @@ def create_sandbox_operation(
11501162
snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration),
11511163
snapshot_retention=snapshot_retention,
11521164
),
1165+
destroy=destroy,
11531166
)
11541167

11551168

11561169
async def get_sandbox(service: SandboxService, **kwargs: Any) -> Sandbox:
11571170
return Sandbox(payload=await service.get_sandbox(**kwargs), service=service)
11581171

11591172

1173+
async def resume_sandbox(service: SandboxService, **kwargs: Any) -> Sandbox:
1174+
return Sandbox(payload=await service.resume_sandbox(**kwargs), service=service)
1175+
1176+
1177+
def resume_sandbox_operation(
1178+
service: SandboxService,
1179+
*,
1180+
name: str,
1181+
project_id: str | None = None,
1182+
include_system_routes: bool | None = None,
1183+
) -> ResumeSandboxOperation:
1184+
return ResumeSandboxOperation(
1185+
service=service,
1186+
params=_ResumeSandboxParams(
1187+
name=name,
1188+
project_id=project_id,
1189+
include_system_routes=include_system_routes,
1190+
),
1191+
)
1192+
1193+
11601194
async def query_sandboxes_page(
11611195
service: SandboxService, **kwargs: Any
11621196
) -> QuerySandboxesPage[Sandbox]:

src/vercel/_internal/unstable/sandbox/service.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,6 @@ async def _wait_for_ready_sandbox(
224224
sandbox = await self.get_sandbox(
225225
name=sandbox.name,
226226
project_id=project_id or sandbox.project_id,
227-
resume=False,
228227
)
229228

230229
async def create_sandbox(
@@ -267,7 +266,7 @@ async def get_sandbox(
267266
*,
268267
name: str,
269268
project_id: str | None = None,
270-
resume: bool = True,
269+
resume: bool = False,
271270
include_system_routes: bool | None = None,
272271
) -> SandboxState:
273272
self._ensure_open()
@@ -336,26 +335,24 @@ async def update_sandbox(
336335
current_snapshot_id=current_snapshot_id,
337336
)
338337

339-
async def create_runtime_session(
338+
async def resume_sandbox(
340339
self,
341340
*,
342341
name: str,
343342
project_id: str | None = None,
344-
resume: bool = True,
345343
include_system_routes: bool | None = None,
346-
) -> SandboxRuntimeSessionState:
344+
) -> SandboxState:
347345
self._ensure_open()
348-
sandbox = await self._api_client.create_runtime_session(
346+
sandbox = await self._api_client.resume_sandbox(
349347
name=name,
350348
project_id=project_id,
351-
resume=resume,
352349
include_system_routes=include_system_routes,
353350
)
354351
if sandbox.current_session is None:
355352
raise SandboxResponseError(
356353
"Sandbox API response is missing object field 'session'", data=sandbox.raw
357354
)
358-
return sandbox.current_session
355+
return sandbox
359356

360357
async def stop_runtime_session(self, *, session_id: str) -> SandboxRuntimeSessionState:
361358
self._ensure_open()

0 commit comments

Comments
 (0)