From cc6b960e98d643ba3b38faec064e18e00f107f4d Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 12 Sep 2026 11:27:13 +0900 Subject: [PATCH 1/2] sdk/python: end the local connection when a proxied guest port closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proxy_port pumped the guest side in one thread and the local side in another, and the pump closed the local socket when the guest reached EOF. On Linux a close cannot wake a thread already blocked in recv on that socket, and the socket outlives the close until that read ends, so the FIN never reaches the client: a connection to a guest port whose upstream refuses hangs instead of ending. Measured against a guest bridge with nothing behind it — 13 ms in the guest, over 30 s through the relay, which turned a caller's five-second retry loop into a three-minute stall. Half-close it instead, the shape the Go SDK already uses, and let the read loop end on its own. The regression test reproduces on Linux only; macOS delivers the FIN either way. --- sdk/python/cocoonsandbox/sandbox.py | 20 ++++++++++------ sdk/python/tests/test_proxy.py | 37 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/sdk/python/cocoonsandbox/sandbox.py b/sdk/python/cocoonsandbox/sandbox.py index 7d57855a..8d182539 100644 --- a/sdk/python/cocoonsandbox/sandbox.py +++ b/sdk/python/cocoonsandbox/sandbox.py @@ -381,22 +381,28 @@ def pump_out(): if not chunk: break local.sendall(chunk) + # half-close: closing here cannot wake the read below, and the + # socket lives until that read ends, so the client never sees EOF. with contextlib.suppress(OSError): - local.close() + local.shutdown(socket.SHUT_WR) pump = threading.Thread(target=pump_out, daemon=True) pump.start() try: - while True: - chunk = local.recv(FS_CHUNK) - if not chunk: - break - guest.send(chunk) + with contextlib.suppress(OSError): + while True: + chunk = local.recv(FS_CHUNK) + if not chunk: + break + guest.send(chunk) # half-close, not close: the guest's reply is still in flight. - guest.close_write() + with contextlib.suppress(SandboxError, OSError): + guest.close_write() pump.join() finally: guest.close() + with contextlib.suppress(OSError): + local.close() def _call(self, op: str, expect: str, **fields) -> dict: with self._dial() as conn: diff --git a/sdk/python/tests/test_proxy.py b/sdk/python/tests/test_proxy.py index e4dc99fb..0f579b60 100644 --- a/sdk/python/tests/test_proxy.py +++ b/sdk/python/tests/test_proxy.py @@ -50,3 +50,40 @@ def test_proxy_port_accepts_and_pipes(monkeypatch): c.close() finally: listener.close() + + +class ClosedPortConn: + """A guest port whose upstream refuses, so silkd reports the close while + the local client is still idle and the proxy is blocked reading it.""" + + def __init__(self): + self._dialled = False + + def send(self, data: bytes) -> None: + pass + + def recv(self) -> bytes: + if not self._dialled: + self._dialled = True + time.sleep(0.3) + return b"" + + def close_write(self) -> None: + pass + + def close(self) -> None: + pass + + +def test_proxy_port_ends_the_local_connection_when_the_guest_port_is_closed(monkeypatch): + sb = Sandbox(client=Client("127.0.0.1:1"), id="sb_1", token="tok", owner="127.0.0.1:1") + monkeypatch.setattr(sb, "dial_port", lambda port: ClosedPortConn()) + + listener = sb.proxy_port("127.0.0.1:0", 8080) + try: + c = socket.create_connection(listener.getsockname(), timeout=5) + c.settimeout(5) + assert c.recv(256) == b"" + c.close() + finally: + listener.close() From b18240af5c440677f58b7a3440b3627eb70cde69 Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 12 Sep 2026 23:20:45 +0900 Subject: [PATCH 2/2] review: tighten the proxy_port fix to the comment and layout rules The WHY was two lines where one carries it, the test helper's docstring restated its own body, and the helper class sat between the two tests instead of above them. --- sdk/python/cocoonsandbox/sandbox.py | 3 +- sdk/python/tests/test_proxy.py | 45 ++++++++++++++--------------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/sdk/python/cocoonsandbox/sandbox.py b/sdk/python/cocoonsandbox/sandbox.py index 8d182539..0f357330 100644 --- a/sdk/python/cocoonsandbox/sandbox.py +++ b/sdk/python/cocoonsandbox/sandbox.py @@ -381,8 +381,7 @@ def pump_out(): if not chunk: break local.sendall(chunk) - # half-close: closing here cannot wake the read below, and the - # socket lives until that read ends, so the client never sees EOF. + # a close cannot wake the read below, and the socket outlives it with contextlib.suppress(OSError): local.shutdown(socket.SHUT_WR) diff --git a/sdk/python/tests/test_proxy.py b/sdk/python/tests/test_proxy.py index 0f579b60..629a8b82 100644 --- a/sdk/python/tests/test_proxy.py +++ b/sdk/python/tests/test_proxy.py @@ -31,30 +31,8 @@ def close(self) -> None: self._closed = True -def test_proxy_port_accepts_and_pipes(monkeypatch): - sb = Sandbox(client=Client("127.0.0.1:1"), id="sb_1", token="tok", owner="127.0.0.1:1") - monkeypatch.setattr(sb, "dial_port", lambda port: FakePortConn()) - - listener = sb.proxy_port("127.0.0.1:0", 8080) - try: - c = socket.create_connection(listener.getsockname(), timeout=5) - c.sendall(b"hello") - c.settimeout(5) - got, deadline = b"", time.time() + 5 - while b"echo:hello" not in got and time.time() < deadline: - chunk = c.recv(256) - if not chunk: - break - got += chunk - assert b"echo:hello" in got, got - c.close() - finally: - listener.close() - - class ClosedPortConn: - """A guest port whose upstream refuses, so silkd reports the close while - the local client is still idle and the proxy is blocked reading it.""" + """A guest port whose upstream refuses, reported while the client is idle.""" def __init__(self): self._dialled = False @@ -75,6 +53,27 @@ def close(self) -> None: pass +def test_proxy_port_accepts_and_pipes(monkeypatch): + sb = Sandbox(client=Client("127.0.0.1:1"), id="sb_1", token="tok", owner="127.0.0.1:1") + monkeypatch.setattr(sb, "dial_port", lambda port: FakePortConn()) + + listener = sb.proxy_port("127.0.0.1:0", 8080) + try: + c = socket.create_connection(listener.getsockname(), timeout=5) + c.sendall(b"hello") + c.settimeout(5) + got, deadline = b"", time.time() + 5 + while b"echo:hello" not in got and time.time() < deadline: + chunk = c.recv(256) + if not chunk: + break + got += chunk + assert b"echo:hello" in got, got + c.close() + finally: + listener.close() + + def test_proxy_port_ends_the_local_connection_when_the_guest_port_is_closed(monkeypatch): sb = Sandbox(client=Client("127.0.0.1:1"), id="sb_1", token="tok", owner="127.0.0.1:1") monkeypatch.setattr(sb, "dial_port", lambda port: ClosedPortConn())