arcbox 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- arcbox/__init__.py +56 -0
- arcbox/_async/__init__.py +5 -0
- arcbox/_async/_client.py +235 -0
- arcbox/_async/commands.py +322 -0
- arcbox/_async/files.py +105 -0
- arcbox/_async/sandbox.py +497 -0
- arcbox/_boundary.py +56 -0
- arcbox/_connection.py +115 -0
- arcbox/_envelope.py +157 -0
- arcbox/_gen/__init__.py +1 -0
- arcbox/_gen/errors_pb2.py +42 -0
- arcbox/_gen/errors_pb2.pyi +66 -0
- arcbox/_gen/filesystem_pb2.py +71 -0
- arcbox/_gen/filesystem_pb2.pyi +173 -0
- arcbox/_gen/process_pb2.py +83 -0
- arcbox/_gen/process_pb2.pyi +245 -0
- arcbox/_gen/sandbox_pb2.py +126 -0
- arcbox/_gen/sandbox_pb2.pyi +427 -0
- arcbox/_gen/snapshot_pb2.py +70 -0
- arcbox/_gen/snapshot_pb2.pyi +119 -0
- arcbox/_gen/template_pb2.py +75 -0
- arcbox/_gen/template_pb2.pyi +148 -0
- arcbox/_sync/__init__.py +6 -0
- arcbox/_sync/_client.py +234 -0
- arcbox/_sync/commands.py +321 -0
- arcbox/_sync/files.py +106 -0
- arcbox/_sync/sandbox.py +496 -0
- arcbox/_types.py +248 -0
- arcbox/errors.py +253 -0
- arcbox/py.typed +0 -0
- arcbox-0.1.1.dist-info/METADATA +231 -0
- arcbox-0.1.1.dist-info/RECORD +33 -0
- arcbox-0.1.1.dist-info/WHEEL +4 -0
arcbox/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""arcbox — run isolated microVM sandboxes on the local ArcBox daemon.
|
|
2
|
+
|
|
3
|
+
Two mirrored surfaces: the sync classes (`Sandbox`, `ArcBox`, ...) and
|
|
4
|
+
their `Async*` counterparts. Public shapes are hand-written and mapped
|
|
5
|
+
at the transport boundary; everything under `arcbox._gen` is generated
|
|
6
|
+
wire code and is deliberately NOT exported. The error hierarchy lives
|
|
7
|
+
in :mod:`arcbox.errors`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from arcbox._async._client import AsyncConnectClient
|
|
11
|
+
from arcbox._async.commands import AsyncCommandHandle, AsyncCommands, AsyncOutputStream
|
|
12
|
+
from arcbox._async.files import AsyncFiles
|
|
13
|
+
from arcbox._async.sandbox import AsyncArcBox, AsyncSandbox
|
|
14
|
+
from arcbox._connection import Connection
|
|
15
|
+
from arcbox._sync._client import ConnectClient
|
|
16
|
+
from arcbox._sync.commands import CommandHandle, Commands, OutputStream
|
|
17
|
+
from arcbox._sync.files import Files
|
|
18
|
+
from arcbox._sync.sandbox import ArcBox, Sandbox
|
|
19
|
+
from arcbox._types import (
|
|
20
|
+
MAX_FILE_BYTES,
|
|
21
|
+
CommandResult,
|
|
22
|
+
IdlePolicy,
|
|
23
|
+
OutputChannel,
|
|
24
|
+
OutputChunk,
|
|
25
|
+
SandboxInfo,
|
|
26
|
+
SandboxState,
|
|
27
|
+
SandboxSummary,
|
|
28
|
+
SignalName,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"MAX_FILE_BYTES",
|
|
33
|
+
"ArcBox",
|
|
34
|
+
"AsyncArcBox",
|
|
35
|
+
"AsyncCommandHandle",
|
|
36
|
+
"AsyncCommands",
|
|
37
|
+
"AsyncConnectClient",
|
|
38
|
+
"AsyncFiles",
|
|
39
|
+
"AsyncOutputStream",
|
|
40
|
+
"AsyncSandbox",
|
|
41
|
+
"CommandHandle",
|
|
42
|
+
"CommandResult",
|
|
43
|
+
"Commands",
|
|
44
|
+
"ConnectClient",
|
|
45
|
+
"Connection",
|
|
46
|
+
"Files",
|
|
47
|
+
"IdlePolicy",
|
|
48
|
+
"OutputChannel",
|
|
49
|
+
"OutputChunk",
|
|
50
|
+
"OutputStream",
|
|
51
|
+
"Sandbox",
|
|
52
|
+
"SandboxInfo",
|
|
53
|
+
"SandboxState",
|
|
54
|
+
"SandboxSummary",
|
|
55
|
+
"SignalName",
|
|
56
|
+
]
|
arcbox/_async/_client.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Connect-over-httpx transport.
|
|
2
|
+
|
|
3
|
+
Unary RPCs are plain POSTs with binary-protobuf bodies
|
|
4
|
+
(``application/proto``); streaming RPCs speak the Connect streaming
|
|
5
|
+
envelope (``application/connect+proto``, framing in ``_envelope``).
|
|
6
|
+
The local tier dials the daemon's Unix socket through httpx's ``uds``
|
|
7
|
+
transport; the same code path serves a future remote tier over HTTPS.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from arcbox._connection import Connection, resolve_connection
|
|
18
|
+
from arcbox._envelope import (
|
|
19
|
+
FLAG_COMPRESSED,
|
|
20
|
+
FLAG_END_STREAM,
|
|
21
|
+
EnvelopeDecoder,
|
|
22
|
+
encode_envelope,
|
|
23
|
+
end_stream_error,
|
|
24
|
+
unary_error,
|
|
25
|
+
)
|
|
26
|
+
from arcbox.errors import ArcBoxError, InvalidArgumentError
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import AsyncIterator, Iterable, Mapping
|
|
30
|
+
from contextlib import AbstractAsyncContextManager
|
|
31
|
+
from types import TracebackType
|
|
32
|
+
|
|
33
|
+
from google.protobuf.message import Message
|
|
34
|
+
|
|
35
|
+
M = TypeVar("M", bound="Message")
|
|
36
|
+
|
|
37
|
+
_UNARY_HEADERS = {
|
|
38
|
+
"content-type": "application/proto",
|
|
39
|
+
"connect-protocol-version": "1",
|
|
40
|
+
}
|
|
41
|
+
_STREAM_HEADERS = {
|
|
42
|
+
"content-type": "application/connect+proto",
|
|
43
|
+
"connect-protocol-version": "1",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _UseDefault:
|
|
48
|
+
"""Sentinel: apply the connection's ``request_timeout``."""
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
# Stable across the async and sync trees so signatures compare
|
|
52
|
+
# equal in the parity test (and read cleanly in help()).
|
|
53
|
+
return "USE_DEFAULT"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_USE_DEFAULT = _UseDefault()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AsyncConnectClient:
|
|
60
|
+
"""One resolved connection to a daemon; every handle shares it."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
connection: Connection | None = None,
|
|
65
|
+
env: Mapping[str, str] | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
resolved = resolve_connection(connection, os.environ if env is None else env)
|
|
68
|
+
#: Per-unary-RPC deadline in seconds, when configured. Streams
|
|
69
|
+
#: and long-polls never receive it.
|
|
70
|
+
self.request_timeout = resolved.request_timeout
|
|
71
|
+
injected = connection.http_client if connection is not None else None
|
|
72
|
+
#: Whether this client constructed (and must eventually close)
|
|
73
|
+
#: its HTTP client; an injected one belongs to the caller.
|
|
74
|
+
self._owns_http = injected is None
|
|
75
|
+
if injected is not None:
|
|
76
|
+
required = httpx.AsyncClient
|
|
77
|
+
if not isinstance(injected, required):
|
|
78
|
+
raise InvalidArgumentError(
|
|
79
|
+
f"Connection.http_client must be an httpx.{required.__name__} for this surface"
|
|
80
|
+
)
|
|
81
|
+
self._http = injected
|
|
82
|
+
return
|
|
83
|
+
headers = {"authorization": f"Bearer {resolved.api_key}"} if resolved.api_key else None
|
|
84
|
+
transport = (
|
|
85
|
+
httpx.AsyncHTTPTransport(uds=resolved.socket_path)
|
|
86
|
+
if resolved.socket_path is not None
|
|
87
|
+
else None
|
|
88
|
+
)
|
|
89
|
+
self._http = httpx.AsyncClient(
|
|
90
|
+
base_url=resolved.base_url,
|
|
91
|
+
transport=transport,
|
|
92
|
+
headers=headers,
|
|
93
|
+
timeout=None,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def aclose(self) -> None:
|
|
97
|
+
"""Release the HTTP client's pooled connections. A no-op when the
|
|
98
|
+
client was injected — it belongs to the caller."""
|
|
99
|
+
if self._owns_http:
|
|
100
|
+
await self._http.aclose()
|
|
101
|
+
|
|
102
|
+
async def unary(
|
|
103
|
+
self,
|
|
104
|
+
path: str,
|
|
105
|
+
request: Message,
|
|
106
|
+
response_type: type[M],
|
|
107
|
+
timeout: float | _UseDefault | None = _USE_DEFAULT,
|
|
108
|
+
) -> M:
|
|
109
|
+
"""One unary RPC. ``timeout`` overrides the configured deadline;
|
|
110
|
+
an explicit ``None`` means no deadline (checkpoint restores take
|
|
111
|
+
as long as they take)."""
|
|
112
|
+
effective = self.request_timeout if isinstance(timeout, _UseDefault) else timeout
|
|
113
|
+
headers = dict(_UNARY_HEADERS)
|
|
114
|
+
if effective is not None:
|
|
115
|
+
headers["connect-timeout-ms"] = str(int(effective * 1000))
|
|
116
|
+
response = await self._http.post(
|
|
117
|
+
path,
|
|
118
|
+
content=request.SerializeToString(),
|
|
119
|
+
headers=headers,
|
|
120
|
+
timeout=None if effective is None else httpx.Timeout(effective),
|
|
121
|
+
)
|
|
122
|
+
if response.status_code != 200:
|
|
123
|
+
raise unary_error(response.status_code, response.content)
|
|
124
|
+
content_type = response.headers.get("content-type", "")
|
|
125
|
+
if not content_type.startswith("application/proto"):
|
|
126
|
+
raise ArcBoxError(f"unexpected unary response content-type {content_type!r}")
|
|
127
|
+
message = response_type()
|
|
128
|
+
message.ParseFromString(response.content)
|
|
129
|
+
return message
|
|
130
|
+
|
|
131
|
+
def stream(self, path: str, request: Message, response_type: type[M]) -> AsyncServerStream[M]:
|
|
132
|
+
"""One server-streaming RPC. Entering the returned context sends
|
|
133
|
+
the request (that is what registers a subscription server-side);
|
|
134
|
+
iterate it for messages."""
|
|
135
|
+
return AsyncServerStream(self._http, path, request, response_type)
|
|
136
|
+
|
|
137
|
+
async def client_stream(
|
|
138
|
+
self, path: str, requests: Iterable[Message], response_type: type[M]
|
|
139
|
+
) -> M:
|
|
140
|
+
"""One client-streaming RPC with the full request sequence known
|
|
141
|
+
up front: the envelopes are sent as one body and the single
|
|
142
|
+
response message is returned."""
|
|
143
|
+
body = b"".join(encode_envelope(0, m.SerializeToString()) for m in requests)
|
|
144
|
+
response = await self._http.post(path, content=body, headers=_STREAM_HEADERS, timeout=None)
|
|
145
|
+
if response.status_code != 200:
|
|
146
|
+
raise unary_error(response.status_code, response.content)
|
|
147
|
+
decoder = EnvelopeDecoder()
|
|
148
|
+
message: M | None = None
|
|
149
|
+
ended = False
|
|
150
|
+
for flags, payload in decoder.feed(response.content):
|
|
151
|
+
if flags & FLAG_COMPRESSED:
|
|
152
|
+
raise ArcBoxError("received a compressed frame without negotiating compression")
|
|
153
|
+
if flags & FLAG_END_STREAM:
|
|
154
|
+
error = end_stream_error(payload)
|
|
155
|
+
if error is not None:
|
|
156
|
+
raise error
|
|
157
|
+
ended = True
|
|
158
|
+
else:
|
|
159
|
+
decoded = response_type()
|
|
160
|
+
decoded.ParseFromString(payload)
|
|
161
|
+
message = decoded
|
|
162
|
+
if not ended:
|
|
163
|
+
# Without the terminal frame a truncated body is
|
|
164
|
+
# indistinguishable from success — never report one as
|
|
165
|
+
# completed (WriteFile rides this path).
|
|
166
|
+
raise ArcBoxError("the stream ended without an EndStreamResponse")
|
|
167
|
+
if message is None:
|
|
168
|
+
raise ArcBoxError("the stream ended without a response message")
|
|
169
|
+
return message
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class AsyncServerStream(Generic[M]):
|
|
173
|
+
"""One server-streaming call: a context manager yielding an iterator.
|
|
174
|
+
|
|
175
|
+
The stream ends deterministically — with the terminal
|
|
176
|
+
EndStreamResponse frame or a typed error, never silence.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
http: httpx.AsyncClient,
|
|
182
|
+
path: str,
|
|
183
|
+
request: Message,
|
|
184
|
+
response_type: type[M],
|
|
185
|
+
) -> None:
|
|
186
|
+
self._http = http
|
|
187
|
+
self._path = path
|
|
188
|
+
self._body = encode_envelope(0, request.SerializeToString())
|
|
189
|
+
self._response_type = response_type
|
|
190
|
+
self._cm: AbstractAsyncContextManager[httpx.Response] | None = None
|
|
191
|
+
self._response: httpx.Response | None = None
|
|
192
|
+
|
|
193
|
+
async def __aenter__(self) -> AsyncServerStream[M]:
|
|
194
|
+
cm = self._http.stream(
|
|
195
|
+
"POST", self._path, content=self._body, headers=_STREAM_HEADERS, timeout=None
|
|
196
|
+
)
|
|
197
|
+
response = await cm.__aenter__()
|
|
198
|
+
if response.status_code != 200:
|
|
199
|
+
body = await response.aread()
|
|
200
|
+
await cm.__aexit__(None, None, None)
|
|
201
|
+
raise unary_error(response.status_code, body)
|
|
202
|
+
self._cm = cm
|
|
203
|
+
self._response = response
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
async def __aexit__(
|
|
207
|
+
self,
|
|
208
|
+
exc_type: type[BaseException] | None,
|
|
209
|
+
exc: BaseException | None,
|
|
210
|
+
tb: TracebackType | None,
|
|
211
|
+
) -> None:
|
|
212
|
+
if self._cm is not None:
|
|
213
|
+
await self._cm.__aexit__(exc_type, exc, tb)
|
|
214
|
+
|
|
215
|
+
def __aiter__(self) -> AsyncIterator[M]:
|
|
216
|
+
return self._messages()
|
|
217
|
+
|
|
218
|
+
async def _messages(self) -> AsyncIterator[M]:
|
|
219
|
+
response = self._response
|
|
220
|
+
if response is None:
|
|
221
|
+
raise ArcBoxError("stream iterated before it was entered")
|
|
222
|
+
decoder = EnvelopeDecoder()
|
|
223
|
+
async for data in response.aiter_bytes():
|
|
224
|
+
for flags, payload in decoder.feed(data):
|
|
225
|
+
if flags & FLAG_COMPRESSED:
|
|
226
|
+
raise ArcBoxError("received a compressed frame without negotiating compression")
|
|
227
|
+
if flags & FLAG_END_STREAM:
|
|
228
|
+
error = end_stream_error(payload)
|
|
229
|
+
if error is not None:
|
|
230
|
+
raise error
|
|
231
|
+
return
|
|
232
|
+
message = self._response_type()
|
|
233
|
+
message.ParseFromString(payload)
|
|
234
|
+
yield message
|
|
235
|
+
raise ArcBoxError("the stream ended without an EndStreamResponse")
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""The ``sandbox.commands`` namespace: run processes inside one sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import TYPE_CHECKING, Literal, overload
|
|
10
|
+
|
|
11
|
+
from google.protobuf import empty_pb2
|
|
12
|
+
|
|
13
|
+
from arcbox._boundary import wrap_errors
|
|
14
|
+
from arcbox._gen import process_pb2
|
|
15
|
+
from arcbox._types import (
|
|
16
|
+
SIGNAL_VALUES,
|
|
17
|
+
CommandResult,
|
|
18
|
+
OutputChunk,
|
|
19
|
+
command_result_from_execution,
|
|
20
|
+
)
|
|
21
|
+
from arcbox.errors import InvalidArgumentError, TimeoutError
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Mapping, Sequence
|
|
25
|
+
from types import TracebackType
|
|
26
|
+
|
|
27
|
+
from arcbox._types import OutputChannel, SignalName
|
|
28
|
+
|
|
29
|
+
from ._client import AsyncConnectClient, AsyncServerStream
|
|
30
|
+
|
|
31
|
+
_PROCESS = "/arcbox.sandbox.v1.SandboxProcessService/"
|
|
32
|
+
|
|
33
|
+
#: Longest single WaitExecution long-poll slice, so a dropped daemon
|
|
34
|
+
#: surfaces as an error instead of an infinite silent wait.
|
|
35
|
+
_WAIT_SLICE_SECONDS = 30
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize_cmd(cmd: str | Sequence[str]) -> list[str]:
|
|
39
|
+
"""``str`` is sugar for a shell command; a sequence is argv, executed
|
|
40
|
+
directly with no shell involved."""
|
|
41
|
+
return ["/bin/sh", "-lc", cmd] if isinstance(cmd, str) else list(cmd)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _channel_name(channel: int) -> OutputChannel:
|
|
45
|
+
if channel == process_pb2.STDIO_CHANNEL_STDERR:
|
|
46
|
+
return "stderr"
|
|
47
|
+
if channel == process_pb2.STDIO_CHANNEL_PTY:
|
|
48
|
+
return "pty"
|
|
49
|
+
return "stdout"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _decode_output(chunks: list[bytes]) -> str:
|
|
53
|
+
return b"".join(chunks).decode("utf-8", errors="replace")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AsyncOutputStream:
|
|
57
|
+
"""A command's output, iterable chunk by chunk.
|
|
58
|
+
|
|
59
|
+
Consuming it to the end releases the underlying HTTP stream
|
|
60
|
+
deterministically. When exiting early (``break``), iterate inside
|
|
61
|
+
the context-manager form — ``async with handle.output as stream`` in
|
|
62
|
+
the async flavor, ``with`` in the sync one — so the stream closes at
|
|
63
|
+
the break instead of whenever the generator finalizer runs."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, chunks: AsyncGenerator[OutputChunk]) -> None:
|
|
66
|
+
self._chunks = chunks
|
|
67
|
+
|
|
68
|
+
def __aiter__(self) -> AsyncIterator[OutputChunk]:
|
|
69
|
+
return self._chunks
|
|
70
|
+
|
|
71
|
+
async def aclose(self) -> None:
|
|
72
|
+
"""Release the underlying HTTP stream without consuming the rest."""
|
|
73
|
+
await self._chunks.aclose()
|
|
74
|
+
|
|
75
|
+
async def __aenter__(self) -> AsyncOutputStream:
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
async def __aexit__(
|
|
79
|
+
self,
|
|
80
|
+
exc_type: type[BaseException] | None,
|
|
81
|
+
exc: BaseException | None,
|
|
82
|
+
tb: TracebackType | None,
|
|
83
|
+
) -> None:
|
|
84
|
+
await self.aclose()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AsyncCommandHandle:
|
|
88
|
+
"""A handle to a running (or finished) command. The process is
|
|
89
|
+
decoupled from this object: dropping the handle never kills the
|
|
90
|
+
process."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, client: AsyncConnectClient, sandbox_id: str, command_id: str) -> None:
|
|
93
|
+
self._client = client
|
|
94
|
+
self._sandbox_id = sandbox_id
|
|
95
|
+
#: Execution id, unique within the sandbox; addressable across clients.
|
|
96
|
+
self.command_id = command_id
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def output(self) -> AsyncOutputStream:
|
|
100
|
+
"""Stream the command's output from the beginning — or from the
|
|
101
|
+
earliest byte the daemon still retains (8 MiB per channel);
|
|
102
|
+
replayed buffered output comes first, then live output follows;
|
|
103
|
+
the stream ends when the process exits (deterministic
|
|
104
|
+
termination — never silence). When breaking out early, iterate
|
|
105
|
+
via the context-manager form (see :class:`AsyncOutputStream`)."""
|
|
106
|
+
return AsyncOutputStream(self._stream_output())
|
|
107
|
+
|
|
108
|
+
async def _stream_output(self) -> AsyncGenerator[OutputChunk]:
|
|
109
|
+
with wrap_errors("commands.output"):
|
|
110
|
+
async with self._attach() as stream:
|
|
111
|
+
async for event in stream:
|
|
112
|
+
kind = event.WhichOneof("event")
|
|
113
|
+
if kind == "output":
|
|
114
|
+
chunk = event.output
|
|
115
|
+
yield OutputChunk(_channel_name(chunk.channel), chunk.data)
|
|
116
|
+
elif kind == "exited":
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
async def wait_for_exit(self, timeout: float | None = None) -> CommandResult:
|
|
120
|
+
"""Wait until the command exits and return its result (server-side
|
|
121
|
+
long-poll; no client-side spinning). ``timeout`` bounds the WAIT,
|
|
122
|
+
not the process: on expiry a :class:`TimeoutError` is raised and
|
|
123
|
+
the process keeps running."""
|
|
124
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
125
|
+
with wrap_errors("commands.wait_for_exit"):
|
|
126
|
+
while True:
|
|
127
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
128
|
+
if remaining is not None and remaining <= 0:
|
|
129
|
+
raise TimeoutError(
|
|
130
|
+
"wait_for_exit(timeout) elapsed before the command exited",
|
|
131
|
+
suggestion=(
|
|
132
|
+
"increase the wait_for_exit timeout argument, or kill() the command"
|
|
133
|
+
),
|
|
134
|
+
context={"command_id": self.command_id},
|
|
135
|
+
)
|
|
136
|
+
if remaining is None:
|
|
137
|
+
execution = await self._wait_slice(_WAIT_SLICE_SECONDS)
|
|
138
|
+
elif remaining >= 1:
|
|
139
|
+
execution = await self._wait_slice(min(_WAIT_SLICE_SECONDS, int(remaining)))
|
|
140
|
+
else:
|
|
141
|
+
# The wire's wait granularity is whole seconds. Poll
|
|
142
|
+
# immediately (an already-exited command returns at
|
|
143
|
+
# once); only if it is still running, sleep out the
|
|
144
|
+
# remainder and take one final poll so an exit inside
|
|
145
|
+
# the budget is still caught — past it, the deadline
|
|
146
|
+
# check above raises.
|
|
147
|
+
execution = await self._wait_slice(0)
|
|
148
|
+
if execution.state != process_pb2.EXECUTION_STATE_EXITED:
|
|
149
|
+
await asyncio.sleep(remaining)
|
|
150
|
+
execution = await self._wait_slice(0)
|
|
151
|
+
if execution.state == process_pb2.EXECUTION_STATE_EXITED:
|
|
152
|
+
break
|
|
153
|
+
return await self._collect_result(execution)
|
|
154
|
+
|
|
155
|
+
async def _wait_slice(self, slice_seconds: int) -> process_pb2.Execution:
|
|
156
|
+
"""One WaitExecution long-poll (0 = an immediate state poll)."""
|
|
157
|
+
return await self._client.unary(
|
|
158
|
+
_PROCESS + "WaitExecution",
|
|
159
|
+
process_pb2.WaitExecutionRequest(
|
|
160
|
+
sandbox_id=self._sandbox_id,
|
|
161
|
+
execution_id=self.command_id,
|
|
162
|
+
timeout_seconds=slice_seconds,
|
|
163
|
+
),
|
|
164
|
+
process_pb2.Execution,
|
|
165
|
+
# Exempt from request_timeout: this unary deliberately parks
|
|
166
|
+
# server-side for the slice; grant it that long plus grace.
|
|
167
|
+
timeout=float(slice_seconds + 5),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
async def kill(self, signal: SignalName = "SIGTERM") -> None:
|
|
171
|
+
"""Deliver a signal to the whole process group (default SIGTERM)."""
|
|
172
|
+
with wrap_errors("commands.kill"):
|
|
173
|
+
await self._client.unary(
|
|
174
|
+
_PROCESS + "SignalExecution",
|
|
175
|
+
process_pb2.SignalExecutionRequest(
|
|
176
|
+
sandbox_id=self._sandbox_id,
|
|
177
|
+
execution_id=self.command_id,
|
|
178
|
+
signal=SIGNAL_VALUES[signal],
|
|
179
|
+
),
|
|
180
|
+
empty_pb2.Empty,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _attach(self) -> AsyncServerStream[process_pb2.ExecutionEvent]:
|
|
184
|
+
return self._client.stream(
|
|
185
|
+
_PROCESS + "AttachExecution",
|
|
186
|
+
process_pb2.AttachExecutionRequest(
|
|
187
|
+
sandbox_id=self._sandbox_id,
|
|
188
|
+
execution_id=self.command_id,
|
|
189
|
+
stdout_offset=0,
|
|
190
|
+
stderr_offset=0,
|
|
191
|
+
),
|
|
192
|
+
process_pb2.ExecutionEvent,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
async def _collect_result(self, execution: process_pb2.Execution) -> CommandResult:
|
|
196
|
+
"""Assemble the result of an exited execution. Output is re-read
|
|
197
|
+
from offset 0 — the daemon retains and replays it, so the result
|
|
198
|
+
is complete even when nobody consumed the live stream, UNLESS the
|
|
199
|
+
command outgrew the daemon's per-channel retention (8 MiB): chunk
|
|
200
|
+
offsets expose the dropped head, reported as ``truncated``."""
|
|
201
|
+
stdout: list[bytes] = []
|
|
202
|
+
stderr: list[bytes] = []
|
|
203
|
+
next_stdout = 0
|
|
204
|
+
next_stderr = 0
|
|
205
|
+
truncated = False
|
|
206
|
+
async with self._attach() as stream:
|
|
207
|
+
async for event in stream:
|
|
208
|
+
kind = event.WhichOneof("event")
|
|
209
|
+
if kind == "output":
|
|
210
|
+
chunk = event.output
|
|
211
|
+
is_stderr = chunk.channel == process_pb2.STDIO_CHANNEL_STDERR
|
|
212
|
+
# A chunk landing past the expected offset means
|
|
213
|
+
# retention already dropped bytes we asked for.
|
|
214
|
+
if chunk.offset > (next_stderr if is_stderr else next_stdout):
|
|
215
|
+
truncated = True
|
|
216
|
+
after = chunk.offset + len(chunk.data)
|
|
217
|
+
if is_stderr:
|
|
218
|
+
next_stderr = after
|
|
219
|
+
stderr.append(chunk.data)
|
|
220
|
+
else:
|
|
221
|
+
next_stdout = after
|
|
222
|
+
stdout.append(chunk.data)
|
|
223
|
+
elif kind == "exited":
|
|
224
|
+
break
|
|
225
|
+
return command_result_from_execution(
|
|
226
|
+
execution, _decode_output(stdout), _decode_output(stderr), truncated
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class AsyncCommands:
|
|
231
|
+
"""Run processes inside one sandbox."""
|
|
232
|
+
|
|
233
|
+
def __init__(self, client: AsyncConnectClient, sandbox_id: str) -> None:
|
|
234
|
+
self._client = client
|
|
235
|
+
self._sandbox_id = sandbox_id
|
|
236
|
+
|
|
237
|
+
@overload
|
|
238
|
+
async def run(
|
|
239
|
+
self,
|
|
240
|
+
cmd: str | Sequence[str],
|
|
241
|
+
*,
|
|
242
|
+
cwd: str | None = None,
|
|
243
|
+
env: Mapping[str, str] | None = None,
|
|
244
|
+
user: str | None = None,
|
|
245
|
+
timeout: float | None = None,
|
|
246
|
+
check: bool = False,
|
|
247
|
+
background: Literal[False] = False,
|
|
248
|
+
) -> CommandResult: ...
|
|
249
|
+
|
|
250
|
+
@overload
|
|
251
|
+
async def run(
|
|
252
|
+
self,
|
|
253
|
+
cmd: str | Sequence[str],
|
|
254
|
+
*,
|
|
255
|
+
cwd: str | None = None,
|
|
256
|
+
env: Mapping[str, str] | None = None,
|
|
257
|
+
user: str | None = None,
|
|
258
|
+
timeout: float | None = None,
|
|
259
|
+
background: Literal[True],
|
|
260
|
+
) -> AsyncCommandHandle: ...
|
|
261
|
+
|
|
262
|
+
async def run(
|
|
263
|
+
self,
|
|
264
|
+
cmd: str | Sequence[str],
|
|
265
|
+
*,
|
|
266
|
+
cwd: str | None = None,
|
|
267
|
+
env: Mapping[str, str] | None = None,
|
|
268
|
+
user: str | None = None,
|
|
269
|
+
timeout: float | None = None,
|
|
270
|
+
check: bool = False,
|
|
271
|
+
background: bool = False,
|
|
272
|
+
) -> CommandResult | AsyncCommandHandle:
|
|
273
|
+
"""Run a command. Foreground (default): returns the complete
|
|
274
|
+
:class:`CommandResult` once the process exits — non-zero exit is
|
|
275
|
+
data unless ``check=True`` (`subprocess.run` semantics).
|
|
276
|
+
Background (``background=True``): returns as soon as the process
|
|
277
|
+
is started, with a handle for streaming and waiting.
|
|
278
|
+
|
|
279
|
+
``timeout`` kills the whole process group after that many
|
|
280
|
+
seconds; expiry surfaces as signal death in the result
|
|
281
|
+
(exit-as-data), not as a raised error."""
|
|
282
|
+
if background and check:
|
|
283
|
+
raise InvalidArgumentError(
|
|
284
|
+
"check=True applies to foreground runs; call "
|
|
285
|
+
".wait_for_exit() and .expect() on the handle instead",
|
|
286
|
+
operation="commands.run",
|
|
287
|
+
)
|
|
288
|
+
handle = await self._start(cmd, cwd=cwd, env=env, user=user, timeout=timeout)
|
|
289
|
+
if background:
|
|
290
|
+
return handle
|
|
291
|
+
result = await handle.wait_for_exit()
|
|
292
|
+
return result.expect() if check else result
|
|
293
|
+
|
|
294
|
+
async def _start(
|
|
295
|
+
self,
|
|
296
|
+
cmd: str | Sequence[str],
|
|
297
|
+
*,
|
|
298
|
+
cwd: str | None,
|
|
299
|
+
env: Mapping[str, str] | None,
|
|
300
|
+
user: str | None,
|
|
301
|
+
timeout: float | None,
|
|
302
|
+
) -> AsyncCommandHandle:
|
|
303
|
+
with wrap_errors("commands.run"):
|
|
304
|
+
# The execution id is minted client-side: a lost response
|
|
305
|
+
# leaves an addressable execution, and retries are idempotent
|
|
306
|
+
# by contract.
|
|
307
|
+
execution_id = str(uuid.uuid4())
|
|
308
|
+
execution = await self._client.unary(
|
|
309
|
+
_PROCESS + "StartExecution",
|
|
310
|
+
process_pb2.StartExecutionRequest(
|
|
311
|
+
sandbox_id=self._sandbox_id,
|
|
312
|
+
execution_id=execution_id,
|
|
313
|
+
cmd=_normalize_cmd(cmd),
|
|
314
|
+
env=dict(env) if env else {},
|
|
315
|
+
working_dir=cwd or "",
|
|
316
|
+
user=user or "",
|
|
317
|
+
timeout_seconds=0 if timeout is None else math.ceil(timeout),
|
|
318
|
+
stdin=False,
|
|
319
|
+
),
|
|
320
|
+
process_pb2.Execution,
|
|
321
|
+
)
|
|
322
|
+
return AsyncCommandHandle(self._client, self._sandbox_id, execution.id)
|