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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forkd
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Open-source fork-on-write microVM sandbox primitive (E2B-compatible surface)
5
5
  Author-email: Deeplethe <info@deeplethe.com>
6
6
  License: Apache-2.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, _, port_s = self.target.rpartition(":")
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
- chunk = s.recv(65536)
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())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forkd
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Open-source fork-on-write microVM sandbox primitive (E2B-compatible surface)
5
5
  Author-email: Deeplethe <info@deeplethe.com>
6
6
  License: Apache-2.0
@@ -6,4 +6,5 @@ forkd/sandbox.py
6
6
  forkd.egg-info/PKG-INFO
7
7
  forkd.egg-info/SOURCES.txt
8
8
  forkd.egg-info/dependency_links.txt
9
- forkd.egg-info/top_level.txt
9
+ forkd.egg-info/top_level.txt
10
+ tests/test_send.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "forkd"
7
- version = "0.5.2"
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