forkd 0.5.2__tar.gz → 0.5.3__tar.gz
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.
- {forkd-0.5.2 → forkd-0.5.3}/PKG-INFO +1 -1
- {forkd-0.5.2 → forkd-0.5.3}/forkd/sandbox.py +38 -4
- {forkd-0.5.2 → forkd-0.5.3}/forkd.egg-info/PKG-INFO +1 -1
- {forkd-0.5.2 → forkd-0.5.3}/forkd.egg-info/SOURCES.txt +2 -1
- {forkd-0.5.2 → forkd-0.5.3}/pyproject.toml +1 -1
- forkd-0.5.3/tests/test_send.py +94 -0
- {forkd-0.5.2 → forkd-0.5.3}/README.md +0 -0
- {forkd-0.5.2 → forkd-0.5.3}/forkd/__init__.py +0 -0
- {forkd-0.5.2 → forkd-0.5.3}/forkd/controller.py +0 -0
- {forkd-0.5.2 → forkd-0.5.3}/forkd.egg-info/dependency_links.txt +0 -0
- {forkd-0.5.2 → forkd-0.5.3}/forkd.egg-info/top_level.txt +0 -0
- {forkd-0.5.2 → forkd-0.5.3}/setup.cfg +0 -0
|
@@ -9,7 +9,27 @@ import socket
|
|
|
9
9
|
import subprocess
|
|
10
10
|
import time
|
|
11
11
|
from dataclasses import dataclass
|
|
12
|
-
from typing import Optional, Sequence, Union
|
|
12
|
+
from typing import Optional, Sequence, Tuple, Union
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _split_host_port(target: str) -> Tuple[str, int]:
|
|
17
|
+
"""Split a ``host:port`` target into ``(host, port)``.
|
|
18
|
+
|
|
19
|
+
Uses ``urlsplit`` rather than ``rpartition(":")`` so IPv6 literals
|
|
20
|
+
like ``[::1]:8888`` parse correctly — ``urlsplit`` strips the
|
|
21
|
+
brackets, handing the bare ``::1`` to ``socket.create_connection``
|
|
22
|
+
(which a naive last-colon split would not, leaving the brackets in
|
|
23
|
+
the host string). Raises ``ValueError`` with an actionable message
|
|
24
|
+
on a malformed target instead of a bare ``int()`` ValueError.
|
|
25
|
+
"""
|
|
26
|
+
parts = urlsplit("//" + target)
|
|
27
|
+
if parts.hostname is None or parts.port is None:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"invalid forkd target {target!r}; expected host:port "
|
|
30
|
+
"(e.g. 10.42.0.2:8888 or [::1]:8888)"
|
|
31
|
+
)
|
|
32
|
+
return parts.hostname, parts.port
|
|
13
33
|
|
|
14
34
|
|
|
15
35
|
@dataclass
|
|
@@ -205,16 +225,30 @@ class Sandbox:
|
|
|
205
225
|
)
|
|
206
226
|
|
|
207
227
|
def _send(self, msg: dict) -> dict:
|
|
208
|
-
host,
|
|
209
|
-
port = int(port_s)
|
|
228
|
+
host, port = _split_host_port(self.target)
|
|
210
229
|
with socket.create_connection((host, port), timeout=5) as s:
|
|
211
230
|
s.settimeout(self.timeout + 5)
|
|
212
231
|
s.sendall((json.dumps(msg) + "\n").encode())
|
|
213
232
|
s.shutdown(socket.SHUT_WR)
|
|
214
233
|
buf = bytearray()
|
|
215
234
|
while True:
|
|
216
|
-
|
|
235
|
+
try:
|
|
236
|
+
chunk = s.recv(65536)
|
|
237
|
+
except socket.timeout as e:
|
|
238
|
+
raise TimeoutError(
|
|
239
|
+
f"forkd: no response from guest agent at {self.target} "
|
|
240
|
+
f"within {self.timeout + 5}s"
|
|
241
|
+
) from e
|
|
217
242
|
if not chunk:
|
|
218
243
|
break
|
|
219
244
|
buf.extend(chunk)
|
|
245
|
+
# An empty body means the agent closed the connection without
|
|
246
|
+
# replying (crash / mid-response death). Surface that plainly
|
|
247
|
+
# rather than a baffling JSONDecodeError on ``json.loads("")`` —
|
|
248
|
+
# matches the TS SDK's empty-body handling in controller.ts.
|
|
249
|
+
if not buf:
|
|
250
|
+
raise RuntimeError(
|
|
251
|
+
f"forkd: empty response from guest agent at {self.target} "
|
|
252
|
+
"(connection closed before any data — the agent may have crashed)"
|
|
253
|
+
)
|
|
220
254
|
return json.loads(buf.decode())
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "forkd"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.3"
|
|
8
8
|
description = "Open-source fork-on-write microVM sandbox primitive (E2B-compatible surface)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{name = "Deeplethe", email = "info@deeplethe.com"}]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Regression tests for Sandbox._send / target parsing (issue #256).
|
|
2
|
+
|
|
3
|
+
These cover the two bugs in the original `_send`:
|
|
4
|
+
1. `rpartition(":")` mangled IPv6 targets — fixed via `_split_host_port`.
|
|
5
|
+
2. an empty response body raised a baffling `JSONDecodeError` instead
|
|
6
|
+
of a clear "agent closed the connection" error.
|
|
7
|
+
|
|
8
|
+
No live guest agent needed: `_split_host_port` is a pure function, and
|
|
9
|
+
`_send` is exercised against a tiny in-process TCP server. `Sandbox` is
|
|
10
|
+
constructed with `spawn=False` so nothing forks.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import socket
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from forkd.sandbox import Sandbox, _split_host_port
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---- _split_host_port (bug 1: IPv6) ----------------------------------------
|
|
22
|
+
|
|
23
|
+
def test_split_ipv4():
|
|
24
|
+
assert _split_host_port("10.42.0.2:8888") == ("10.42.0.2", 8888)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_split_hostname():
|
|
28
|
+
assert _split_host_port("agent.local:9000") == ("agent.local", 9000)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_split_ipv6_bracketed():
|
|
32
|
+
# The original rpartition(":") left brackets in the host; urlsplit
|
|
33
|
+
# strips them so the bare address reaches create_connection.
|
|
34
|
+
assert _split_host_port("[::1]:8888") == ("::1", 8888)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_split_ipv6_full():
|
|
38
|
+
assert _split_host_port("[2001:db8::1]:443") == ("2001:db8::1", 443)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize("bad", ["nohost", "10.42.0.2", "host:", ":8888", "host:notaport"])
|
|
42
|
+
def test_split_malformed_raises_valueerror(bad):
|
|
43
|
+
with pytest.raises(ValueError):
|
|
44
|
+
_split_host_port(bad)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---- _send against a local server ------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _serve_once(reply: bytes):
|
|
50
|
+
"""Start a one-shot TCP server on 127.0.0.1; return its 'host:port'.
|
|
51
|
+
Each connection gets `reply` then the socket closes."""
|
|
52
|
+
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
53
|
+
srv.bind(("127.0.0.1", 0))
|
|
54
|
+
srv.listen(1)
|
|
55
|
+
host, port = srv.getsockname()
|
|
56
|
+
|
|
57
|
+
def handle():
|
|
58
|
+
conn, _ = srv.accept()
|
|
59
|
+
with conn:
|
|
60
|
+
# Drain the request (the client SHUT_WR after sending).
|
|
61
|
+
while conn.recv(65536):
|
|
62
|
+
pass
|
|
63
|
+
if reply:
|
|
64
|
+
conn.sendall(reply)
|
|
65
|
+
srv.close()
|
|
66
|
+
|
|
67
|
+
threading.Thread(target=handle, daemon=True).start()
|
|
68
|
+
return f"{host}:{port}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _sandbox_for(target: str) -> Sandbox:
|
|
72
|
+
return Sandbox(target=target, timeout=2, spawn=False)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_send_roundtrip_ok():
|
|
76
|
+
target = _serve_once((json.dumps({"ok": True, "echo": 1}) + "\n").encode())
|
|
77
|
+
sb = _sandbox_for(target)
|
|
78
|
+
assert sb._send({"ping": 1}) == {"ok": True, "echo": 1}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_send_empty_response_raises_clear_error():
|
|
82
|
+
# bug 2: server closes without replying. The RuntimeError("empty
|
|
83
|
+
# response") proves we no longer surface a baffling JSONDecodeError
|
|
84
|
+
# from json.loads("").
|
|
85
|
+
target = _serve_once(b"")
|
|
86
|
+
sb = _sandbox_for(target)
|
|
87
|
+
with pytest.raises(RuntimeError, match="empty response"):
|
|
88
|
+
sb._send({"ping": 1})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_send_invalid_target_raises_before_connect():
|
|
92
|
+
sb = _sandbox_for("not-a-target")
|
|
93
|
+
with pytest.raises(ValueError, match="invalid forkd target"):
|
|
94
|
+
sb._send({"ping": 1})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|