Skip to content

sdk/python: end the local connection when a proxied guest port closes - #158

Merged
CMGS merged 2 commits into
mainfrom
fix/proxy-port-close
Sep 12, 2026
Merged

CMGS merged 2 commits into
mainfrom
fix/proxy-port-close

Conversation

@CMGS

@CMGS CMGS commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

proxy_port runs two threads per connection: one pumps the guest side into the local socket, the other pumps the local socket into the guest. When the guest side reached EOF the first thread closed the local socket.

On Linux that close cannot wake the second thread, which is already blocked in recv on that same 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:

in the guest,  curl 127.0.0.1:9222  ->  000 in 13 ms      (the bridge closes correctly)
through proxy_port,  connect + read ->  still blocked at 30 s
dial_port on the same port          ->  EOF in 0.01 s     (silkd and the SDK's own stream are fine)

A caller that retries on a refused connection pays the full socket timeout instead: OSWorld's browser setup retries every five seconds for fifteen attempts, and the first attempt alone cost 180 seconds on every task that launches a browser.

Half-closing is the fix, and it is the shape sdk/go's proxyConn already uses — closeWrite(local) once io.Copy(local, guest) returns. The local read loop then ends on its own when the client closes, and the socket is closed in the finally once both directions are done. The read loop also stops raising OSError into a thread that has no handler for it.

Verification

The regression test drives proxy_port with a guest conn that reports EOF while the local client is idle, which is the state the race needs:

Linux, released code : FAILED … TimeoutError: timed out
Linux, with the fix  : 2 passed

macOS delivers the FIN either way, so the test only reproduces on Linux — which is where CI runs it.

Gates from sdk/python: ruff format --check clean, ruff check clean, pytest 156 passed.

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.
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.
@CMGS
CMGS merged commit d96bd2f into main Sep 12, 2026
2 checks passed
@CMGS
CMGS deleted the fix/proxy-port-close branch September 12, 2026 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant