Skip to content

Commit 4fde86d

Browse files
committed
Collect sandbox command output from process start
1 parent 14c3e5b commit 4fde86d

16 files changed

Lines changed: 876 additions & 69 deletions

‎README.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,44 @@ sandbox.files.write(
295295
)
296296
```
297297

298+
### Run commands and collect output
299+
300+
`sandbox.exec()` and `sandbox.processes.start()` stream command output from the
301+
receiver as soon as execution starts. The SDK collects stdout and stderr in
302+
memory, so a completed result is not limited to the receiver's replay buffer.
303+
This requires a receiver supporting streaming `POST /sandbox/processes`; roll
304+
out the receiver before upgrading the SDK.
305+
306+
```python
307+
result = sandbox.exec("make test", max_output_bytes=128 * 1024 * 1024)
308+
print(result.stdout, result.stderr, result.exit_code)
309+
310+
process = sandbox.processes.start("make test")
311+
try:
312+
for event in process.stream():
313+
if event.type == "stdout":
314+
print(event.data, end="")
315+
result = process.wait()
316+
finally:
317+
process.disconnect()
318+
```
319+
320+
The combined output limit defaults to 64 MiB per command and can be adjusted
321+
with `max_output_bytes`. Exceeding it raises `output_limit_exceeded`. A broken
322+
stream, missing output, or receiver truncation raises `incomplete_output`.
323+
These errors include the process ID and do not automatically rerun the command.
324+
325+
`start()` returns after the process starts and collects in the background.
326+
`wait(timeout_sec=...)` limits the local wait; collection continues after a wait
327+
timeout. The timeout passed to `start()` or `exec()` limits command execution.
328+
A local wait timeout raises `TimeoutError` (`asyncio.TimeoutError` in the async API).
329+
`disconnect()` stops collection and leaves the command running. Use `kill()` to
330+
stop it. Reattaching with `get()` can retrieve only retained receiver output;
331+
`wait()` raises if that output has been truncated.
332+
333+
The async API has the same behavior: await `exec()`, `start()`, `wait()`, and
334+
`disconnect()`, and use `async for` with `stream()`.
335+
298336
### Resume terminal output after reconnect
299337

300338
```python

‎hyperbrowser/client/managers/async_manager/sandbox.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ async def exec(
307307
timeout_ms: Optional[int] = None,
308308
timeout_sec: Optional[int] = None,
309309
run_as: Optional[str] = None,
310+
max_output_bytes: int = 64 * 1024 * 1024,
310311
):
311312
return await self.processes.exec(
312313
input,
@@ -315,6 +316,7 @@ async def exec(
315316
timeout_ms=timeout_ms,
316317
timeout_sec=timeout_sec,
317318
run_as=run_as,
319+
max_output_bytes=max_output_bytes,
318320
)
319321

320322
async def get_process(self, process_id: str) -> SandboxProcessHandle:

‎hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py‎

Lines changed: 126 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import base64
23
from typing import AsyncIterator, Dict, Optional, Union
34

@@ -16,6 +17,11 @@
1617
SandboxProcessStdinParams as SandboxProcessStdinParamsDict,
1718
)
1819
from ...sandboxes.shared import _normalize_exec_params
20+
from ...sandboxes.process_output import (
21+
DEFAULT_MAX_PROCESS_OUTPUT_BYTES,
22+
ProcessOutput,
23+
validate_output_limit,
24+
)
1925
from .sandbox_transport import RuntimeTransport
2026

2127
DEFAULT_PROCESS_KILL_WAIT_SECONDS = 5.0
@@ -25,6 +31,10 @@ class SandboxProcessHandle:
2531
def __init__(self, transport: RuntimeTransport, summary: SandboxProcessSummary):
2632
self._transport = transport
2733
self._summary = summary
34+
self._output = None
35+
self._collector = None
36+
self._events = None
37+
self._changed = asyncio.Event()
2838

2939
@property
3040
def id(self) -> str:
@@ -50,6 +60,15 @@ async def wait(
5060
timeout_ms: Optional[int] = None,
5161
timeout_sec: Optional[int] = None,
5262
) -> SandboxProcessResult:
63+
if self._collector is not None:
64+
timeout = None
65+
if timeout_sec is not None and timeout_sec > 0:
66+
timeout = timeout_sec
67+
elif timeout_ms is not None and timeout_ms > 0:
68+
timeout = timeout_ms / 1000
69+
if not self._collector.done():
70+
await asyncio.wait_for(asyncio.shield(self._collector), timeout)
71+
return self._collected_result()
5372
payload = await self._transport.request_json(
5473
f"/sandbox/processes/{self.id}/wait",
5574
method="POST",
@@ -60,6 +79,10 @@ async def wait(
6079
headers={"content-type": "application/json"},
6180
)
6281
result = SandboxProcessResult(**payload["result"])
82+
if result.output_truncated:
83+
raise ProcessOutput(self.id, 0).failure(
84+
"Retained process output is incomplete; collect output from process start"
85+
)
6386
self._summary = SandboxProcessSummary(
6487
id=result.id,
6588
status=result.status,
@@ -133,6 +156,21 @@ async def write_stdin(
133156
)
134157

135158
async def stream(self, from_seq: Optional[int] = None) -> AsyncIterator[object]:
159+
if self._output is not None:
160+
index = 0
161+
while True:
162+
self._changed.clear()
163+
while index < len(self._output.events):
164+
event = self._output.events[index]
165+
index += 1
166+
if from_seq is None or event.seq >= from_seq:
167+
yield event
168+
if self._collector.done():
169+
yield SandboxProcessExitEvent(
170+
type="exit", result=self._collected_result()
171+
)
172+
return
173+
await self._changed.wait()
136174
params = {"from_seq": from_seq} if from_seq and from_seq > 0 else None
137175
async for event in self._transport.stream_sse(
138176
f"/sandbox/processes/{self.id}/stream",
@@ -153,6 +191,70 @@ async def stream(self, from_seq: Optional[int] = None) -> AsyncIterator[object]:
153191
result=SandboxProcessResult(**data),
154192
)
155193

194+
def _collected_result(self) -> SandboxProcessResult:
195+
if self._output.error is not None:
196+
raise self._output.error
197+
if self._output.result is None:
198+
raise self._output.failure(
199+
"Command stream ended before its completion event"
200+
)
201+
result = self._output.result
202+
self._summary = self._summary.model_copy(
203+
update={
204+
"status": result.status,
205+
"exit_code": result.exit_code,
206+
"completed_at": result.completed_at,
207+
}
208+
)
209+
return result
210+
211+
async def _collect(self) -> None:
212+
try:
213+
async for event in self._events:
214+
self._output.consume(event)
215+
self._changed.set()
216+
if self._output.result is not None:
217+
return
218+
self._output.error = self._output.failure(
219+
"Command stream ended before its completion event"
220+
)
221+
except asyncio.CancelledError:
222+
if self._output.result is None:
223+
self._output.error = self._output.failure(
224+
"Command output collection disconnected"
225+
)
226+
except Exception as error:
227+
self._output.error = (
228+
self._output.failure(str(error))
229+
if not hasattr(error, "code")
230+
else error
231+
)
232+
finally:
233+
try:
234+
await self._events.aclose()
235+
except Exception as error:
236+
if self._output.result is None and self._output.error is None:
237+
self._output.error = self._output.failure(str(error))
238+
finally:
239+
self._changed.set()
240+
241+
async def disconnect(self) -> None:
242+
"""Stop collecting output; the detached command continues running."""
243+
if self._collector is not None and not self._collector.done():
244+
if self._output.result is None and self._output.error is None:
245+
self._output.error = self._output.failure(
246+
"Command output collection disconnected"
247+
)
248+
self._collector.cancel()
249+
try:
250+
await self._collector
251+
except asyncio.CancelledError:
252+
pass
253+
finally:
254+
# Cancellation may happen before the collector gets its first turn.
255+
await self._events.aclose()
256+
self._changed.set()
257+
156258
async def result(self) -> SandboxProcessResult:
157259
return await self.wait()
158260

@@ -170,22 +272,21 @@ async def exec(
170272
timeout_ms: Optional[int] = None,
171273
timeout_sec: Optional[int] = None,
172274
run_as: Optional[str] = None,
275+
max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES,
173276
) -> SandboxProcessResult:
174-
params = _normalize_exec_params(
277+
handle = await self.start(
175278
input,
176279
cwd=cwd,
177280
env=env,
178281
timeout_ms=timeout_ms,
179282
timeout_sec=timeout_sec,
180283
run_as=run_as,
284+
max_output_bytes=max_output_bytes,
181285
)
182-
payload = await self._transport.request_json(
183-
"/sandbox/exec",
184-
method="POST",
185-
json_body=dump_request(params, SandboxExecParams),
186-
headers={"content-type": "application/json"},
187-
)
188-
return SandboxProcessResult(**payload["result"])
286+
try:
287+
return await handle.wait()
288+
finally:
289+
await handle.disconnect()
189290

190291
async def start(
191292
self,
@@ -196,7 +297,9 @@ async def start(
196297
timeout_ms: Optional[int] = None,
197298
timeout_sec: Optional[int] = None,
198299
run_as: Optional[str] = None,
300+
max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES,
199301
) -> SandboxProcessHandle:
302+
validate_output_limit(max_output_bytes)
200303
params = _normalize_exec_params(
201304
input,
202305
cwd=cwd,
@@ -205,16 +308,25 @@ async def start(
205308
timeout_sec=timeout_sec,
206309
run_as=run_as,
207310
)
208-
payload = await self._transport.request_json(
311+
events = self._transport.stream_sse(
209312
"/sandbox/processes",
210313
method="POST",
211314
json_body=dump_request(params, SandboxExecParams),
212-
headers={"content-type": "application/json"},
213-
)
214-
return SandboxProcessHandle(
215-
self._transport,
216-
SandboxProcessSummary(**payload["process"]),
217315
)
316+
try:
317+
started = await events.__anext__()
318+
if started["event"] != "started":
319+
raise RuntimeError("Expected process start event")
320+
handle = SandboxProcessHandle(
321+
self._transport, SandboxProcessSummary(**started["data"])
322+
)
323+
except BaseException:
324+
await events.aclose()
325+
raise
326+
handle._events = events
327+
handle._output = ProcessOutput(handle.id, max_output_bytes)
328+
handle._collector = asyncio.create_task(handle._collect())
329+
return handle
218330

219331
async def get(self, process_id: str) -> SandboxProcessHandle:
220332
payload = await self._transport.request_json(f"/sandbox/processes/{process_id}")

‎hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py‎

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import httpx
55

6+
from .....exceptions import HyperbrowserError
67
from .....sandbox_common import (
78
RuntimeConnection,
89
build_headers,
@@ -83,9 +84,16 @@ async def stream_bytes(
8384
await client.aclose()
8485

8586
async def stream_sse(
86-
self, path: str, params: Optional[Dict[str, object]] = None
87+
self,
88+
path: str,
89+
params: Optional[Dict[str, object]] = None,
90+
*,
91+
method: str = "GET",
92+
json_body: Optional[Dict[str, object]] = None,
8793
) -> AsyncIterator[Dict[str, object]]:
88-
client, response = await self._open_stream(path, params=params)
94+
client, response = await self._open_stream(
95+
path, params=params, method=method, json_body=json_body
96+
)
8997
event_name = "message"
9098
event_id = None
9199
data_lines = []
@@ -194,18 +202,37 @@ async def _open_stream(
194202
*,
195203
params: Optional[Dict[str, object]] = None,
196204
allow_refresh: bool = True,
205+
method: str = "GET",
206+
json_body: Optional[Dict[str, object]] = None,
197207
):
198208
connection = await self._resolve_connection(False)
199-
client, response = await self._send_stream(connection, path, params=params)
209+
client, response = await self._send_stream(
210+
connection, path, params=params, method=method, json_body=json_body
211+
)
200212
if response.status_code == 401 and allow_refresh:
201213
await response.aclose()
202214
await client.aclose()
203215
refreshed = await self._resolve_connection(True)
204-
client, response = await self._send_stream(refreshed, path, params=params)
216+
client, response = await self._send_stream(
217+
refreshed, path, params=params, method=method, json_body=json_body
218+
)
205219

206-
if not response.is_success:
207-
await response.aread()
208-
ensure_response_ok(response, "runtime")
220+
try:
221+
if not response.is_success:
222+
await response.aread()
223+
ensure_response_ok(response, "runtime")
224+
if method == "POST" and "text/event-stream" not in response.headers.get(
225+
"content-type", ""
226+
):
227+
raise HyperbrowserError(
228+
"Receiver does not support streaming command start; update the receiver. The command may have started; do not retry it automatically.",
229+
code="streaming_not_supported",
230+
service="runtime",
231+
)
232+
except BaseException:
233+
await response.aclose()
234+
await client.aclose()
235+
raise
209236
return client, response
210237

211238
async def _open_binary_stream(
@@ -320,6 +347,8 @@ async def _send_stream(
320347
path: str,
321348
*,
322349
params: Optional[Dict[str, object]],
350+
method: str = "GET",
351+
json_body: Optional[Dict[str, object]] = None,
323352
):
324353
request_path = _build_query_path(path, params)
325354
target = resolve_runtime_transport_target(
@@ -335,7 +364,9 @@ async def _send_stream(
335364
client = httpx.AsyncClient(timeout=self._timeout)
336365

337366
try:
338-
request = client.build_request("GET", target.url, headers=headers)
367+
request = client.build_request(
368+
method, target.url, headers=headers, json=json_body
369+
)
339370
response = await client.send(request, stream=True)
340371
return client, response
341372
except BaseException as error:
@@ -344,5 +375,5 @@ async def _send_stream(
344375
error,
345376
"runtime",
346377
"Unknown runtime request error",
347-
request_context("GET", path),
378+
request_context(method, path),
348379
)

0 commit comments

Comments
 (0)