Skip to content

Commit e8ff98d

Browse files
fix: survive a host that cannot hand a socket to a worker (PyPy on Windows)
16 of 17 CI jobs are green; this is the seventeenth. PyPy on Windows has `socket.share` as a name but the call does not work, so the capability check passed and the failure surfaced three steps later as `EOFError: bad socket handshake: b''` -- with the master gateway dead and no explanation anywhere. Three separate defects behind that one symptom: * the capability is now settled by *doing* it once, against our own pid, rather than by `hasattr`. A host that cannot hand over a socket refuses the request up front, where a reason can still reach the coordinator. * a failed socket gateway no longer kills the gateway it was requested through. It runs as a task on that worker's host, so asking for one unsupported sub-gateway cost the master too -- which is why the errors cascaded across 51 tests instead of failing one. * `channel.send`/`receive` check for a foreign event loop before checking whether the channel is closed. Both are caller bugs, but which one you were told about depended on whether the peer had closed yet, so the event-loop diagnostic lost a race on the slower interpreter. The socket transport simply cannot work where neither `pass_fds` nor a working `share()` exists, so those gateways skip there with that reason rather than failing. Removing the limitation means not handing the socket over at all -- spawning the worker as the listener and reporting its address back -- which is a bigger change than this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 39251b9 commit e8ff98d

7 files changed

Lines changed: 66 additions & 8 deletions

File tree

‎CHANGELOG.rst‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@
4545
by name. The launch command no longer needs ``head -c <N>`` byte
4646
accounting, a ``mktemp`` prelude, or ``exec`` to keep an fd alive, and the
4747
protocol stream never carries a payload.
48+
* ``channel.send()`` and ``channel.receive()`` check for a foreign event
49+
loop before they check whether the channel is still open. Calling a
50+
blocking API from inside a loop is a caller bug either way, and which of
51+
the two errors you got depended on whether the peer had closed yet --
52+
so the more useful message lost a race.
53+
* Whether a socket can be handed to a worker is now settled by *doing* it
54+
once rather than by looking for ``socket.share``. An implementation with
55+
the name but not a working call -- PyPy on Windows -- otherwise passed
56+
the check and failed later, at the point where the only thing left to
57+
tell the coordinator was a closed socket. Such a host now refuses the
58+
request up front, and ``socket=``/``installvia=`` gateways are skipped
59+
there rather than failing.
60+
* A socket gateway that fails to start no longer takes down the gateway it
61+
was requested through. It ran as a task on that worker's host, so an
62+
unsupported sub-gateway used to cost the master as well.
4863
* ``execnet server :0`` reported a port nothing was listening on. Binding a
4964
wildcard host with an ephemeral port gives *each* address family its own
5065
random port, and only the first was reported -- so a client dialling the

‎src/execnet/_channel.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,12 @@ def send(self, item: object) -> None:
328328
329329
OSError is raised if the write pipe was prematurely closed.
330330
"""
331+
# before the state check: calling a blocking API from inside an event
332+
# loop is a bug in the caller either way, and whether the channel has
333+
# closed yet is a race -- the diagnostic should not depend on it
334+
self.gateway._check_event_loop("channel.send()")
331335
if self.isclosed():
332336
raise OSError(f"cannot send to {self!r}")
333-
self.gateway._check_event_loop("channel.send()")
334337
self.gateway._send(Message.CHANNEL_DATA, self.id, dumps_internal(item))
335338

336339
def receive(self, timeout: float | None = None) -> Any:
@@ -344,10 +347,10 @@ def receive(self, timeout: float | None = None) -> Any:
344347
reraised as channel.RemoteError exceptions containing
345348
a textual representation of the remote traceback.
346349
"""
350+
self.gateway._check_event_loop("channel.receive()")
347351
mailbox = self._mailbox
348352
if mailbox is None:
349353
raise OSError("cannot receive(), channel has receiver callback")
350-
self.gateway._check_event_loop("channel.receive()")
351354
x = mailbox.get(timeout)
352355
if x is ENDMARKER:
353356
mailbox.put(x) # for other receivers

‎src/execnet/_provision.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
TRANSPORTS = ("socket", "stdio")
4444

4545

46+
@cache
4647
def socket_handoff_available() -> bool:
4748
"""Whether we can hand a socket to a worker process we spawn ourselves.
4849
@@ -51,11 +52,26 @@ def socket_handoff_available() -> bool:
5152
(``WSADuplicateSocket``) duplicates the socket into a named pid, and the
5253
resulting blob rides in the worker config -- see the ``share`` transport
5354
in ``_trio_worker``.
55+
56+
The Windows answer is settled by *doing* it once, against our own pid,
57+
rather than by looking for the method. An implementation that has the
58+
name but not a working call -- PyPy on Windows -- would otherwise pass
59+
the check and fail at the point where the only thing left to tell the
60+
coordinator is a closed socket.
5461
"""
5562
import socket as _socket
5663

57-
if sys.platform.startswith("win"):
58-
return hasattr(_socket.socket, "share")
64+
if not socket_share_required():
65+
return True
66+
try:
67+
left, right = _socket.socketpair()
68+
try:
69+
left.share(os.getpid()) # type: ignore[attr-defined] # Windows
70+
finally:
71+
left.close()
72+
right.close()
73+
except Exception:
74+
return False
5975
return True
6076

6177

‎src/execnet/_trio_host.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -901,7 +901,14 @@ async def _start_socket_and_reply(
901901
stream = await listeners[0].accept()
902902
for listener in listeners:
903903
await listener.aclose()
904-
await serve_socket_connection(stream, reap=True)
904+
try:
905+
await serve_socket_connection(stream, reap=True)
906+
except Exception as exc:
907+
# This runs as a task on *this worker's* host: letting it propagate
908+
# tears the whole gateway down, so a coordinator asking for one
909+
# unsupported sub-gateway would lose the master it asked through.
910+
# The connection is already closed, so the coordinator gets its EOF.
911+
trace(f"socket gateway for channel {channelid} failed: {exc!r}")
905912

906913

907914
def handle_start_socket(gateway: BaseGateway, channelid: int, data: bytes) -> None:

‎testing/conftest.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import execnet
1414
from execnet import Gateway
15+
from execnet import _provision
1516
from execnet._execmodel import ExecModel
1617
from execnet._execmodel import get_execmodel
1718

@@ -188,6 +189,11 @@ def gw(
188189
if request.param == "popen":
189190
gw = group.makegateway("popen//id=popen//profile=%s" % profile)
190191
elif request.param == "socket":
192+
if not _provision.socket_handoff_available():
193+
# the server accepts the connection and must then give it to
194+
# a worker process; where neither pass_fds nor a working
195+
# socket.share() exists (PyPy on Windows) there is no way to
196+
pytest.skip("this interpreter cannot hand a socket to a worker")
191197
pname = "sproxy1"
192198
if pname not in group:
193199
proxygw = group.makegateway("popen//id=%s" % pname)

‎testing/test_socketserver_cli.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,19 @@
1414
import pytest
1515

1616
import execnet
17+
from execnet import _provision
1718

1819
SERVER = shutil.which("execnet-socketserver")
1920

20-
pytestmark = pytest.mark.skipif(
21-
SERVER is None, reason="execnet-socketserver console script not installed"
22-
)
21+
pytestmark = [
22+
pytest.mark.skipif(
23+
SERVER is None, reason="execnet-socketserver console script not installed"
24+
),
25+
pytest.mark.skipif(
26+
not _provision.socket_handoff_available(),
27+
reason="the server must hand the accepted socket to a worker process",
28+
),
29+
]
2330

2431

2532
@pytest.fixture

‎testing/test_xspec.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,10 @@ def test_socket_second(
253253
assert rinfo.cwd == rinfo2.cwd
254254
assert rinfo.version_info == rinfo2.version_info
255255

256+
@pytest.mark.skipif(
257+
not _provision.socket_handoff_available(),
258+
reason="the server must hand the accepted socket to a worker process",
259+
)
256260
def test_socket_installvia(self) -> None:
257261
group = execnet.Group()
258262
group.makegateway("popen//id=p1")

0 commit comments

Comments
 (0)