Skip to content

Commit 2fc4013

Browse files
feat!: default every spawned worker to the socket transport
Windows was held on `transport=stdio` until the share path had a green run behind it. It has one now -- all 17 jobs, including `windows-latest, pypy-3.11`, which was the case that took four rounds to pin down. So the platform split goes away: a worker execnet spawns itself gets a socket, by whichever handoff the platform has. That also retires the machinery the split needed. `default=` existed only to say "can, but does not yet", and `default_spawn_transport()` only to compute it; with the answer the same on both platforms they collapse back into `resolve_transport(spec, available=...)`, which is where this started before Windows needed an exception. Two tests lose their `posix_only` marks and now cover Windows: that the protocol really is off fd 0/1 (asserting `--protocol-share` there rather than `--protocol-fd`), and that a worker's stdout reaches the coordinator. The visible consequence on Windows is that one: remote `print()` now goes to the coordinator's console instead of being folded onto stderr, matching POSIX. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3a8f182 commit 2fc4013

5 files changed

Lines changed: 43 additions & 55 deletions

File tree

CHANGELOG.rst

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@
2020
``import execnet, trio`` probe used to decide whether a ``python=``
2121
interpreter can host a worker directly.
2222
* The protocol no longer has to be the worker's stdin/stdout. A new
23-
``transport=socket|stdio`` spec key selects; it defaults to ``socket`` on
24-
POSIX and ``stdio`` on Windows, where neither ``pass_fds`` nor ``ssh -R``
25-
unix-socket forwarding is available. ``socket`` means an inherited
26-
socketpair for ``popen`` and an ``ssh -R``-forwarded unix socket that the
27-
worker dials back on for ``ssh=``/``vagrant_ssh=``.
23+
``transport=socket|stdio`` spec key selects, and ``socket`` is the default
24+
for every worker execnet spawns itself: an inherited socketpair for
25+
``popen`` on POSIX, and a socket duplicated with ``socket.share()`` on
26+
Windows. ``ssh=``/``vagrant_ssh=`` gateways use an ``ssh -R``-forwarded
27+
unix socket the worker dials back on, which needs ``AF_UNIX`` and
28+
``StreamLocal`` forwarding, so those stay on ``stdio`` on Windows.
2829
* **A worker's stdio now belongs to the code it runs.** It used to be
2930
redirected to the null device so it could not corrupt the protocol, which
3031
meant a remote ``print()`` went nowhere at all. With the socket transport
@@ -51,12 +52,12 @@
5152
the two errors you got depended on whether the peer had closed yet --
5253
so the more useful message lost a race.
5354
* 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.
55+
once -- sharing to our own pid and rebuilding the result -- rather than
56+
by looking for ``socket.share``. An implementation with the name but not
57+
a working call would otherwise pass the check and fail later, at the
58+
point where the only thing left to tell the coordinator is a closed
59+
socket. A host that genuinely cannot hand a socket over refuses the
60+
request up front instead.
6061
* A socket gateway that fails to start no longer takes down the gateway it
6162
was requested through. It ran as a task on that worker's host, so an
6263
unsupported sub-gateway used to cost that coordinator as well.
@@ -80,10 +81,12 @@
8081
``socket.share()`` (``WSADuplicateSocket``); because that needs the
8182
child's pid, the flag travels in argv and the blob follows in the config
8283
on stdin. The blob is bound to that one pid, so it is inert to anything
83-
else. This makes ``transport=socket`` work on Windows for ``popen`` and
84-
fixes ``socket=``/``installvia=`` gateways served from a Windows host.
85-
Windows still *defaults* to ``transport=stdio``; ask for
86-
``transport=socket`` to opt in.
84+
else. This makes ``transport=socket`` the Windows default too, and fixes
85+
``socket=``/``installvia=`` gateways served from a Windows host. A socket
86+
is handed over *as a socket* rather than reduced to its handle: rebuilding
87+
one from a bare handle makes the constructor re-derive family/type/proto
88+
by querying it, which PyPy on Windows cannot do to a handle that came
89+
from ``WSADuplicateSocket``.
8790
* ``transport=socket`` on a gateway that cannot provide it is now an error
8891
at ``makegateway`` time naming the platform, instead of a gateway that
8992
waits for a worker which was never able to reach back. ssh dial-back
@@ -95,8 +98,8 @@
9598
POSIX-only. Windows has no async equivalent -- trio's Windows pipe streams
9699
need OVERLAPPED handles registered with an IOCP, and the stdio a process
97100
inherits is an ordinary synchronous pipe -- so those reads and writes now
98-
run in the thread pool. The socket transport, where it is available, still
99-
needs no threads.
101+
run in the thread pool. This only affects ``transport=stdio``; the socket
102+
transport, which is the default, needs no threads.
100103
* New ``EXECNET_PROVISION_WHEEL`` environment variable naming a prebuilt
101104
wheel to provision remote workers from, instead of resolving the
102105
coordinator's version from an index or building one from its source tree.

src/execnet/_provision.py

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -107,35 +107,17 @@ def ssh_dialback_available() -> bool:
107107
return hasattr(_socket, "AF_UNIX") and not sys.platform.startswith("win")
108108

109109

110-
def default_spawn_transport() -> str:
111-
"""What a worker we spawn gets when the spec does not say.
112-
113-
Windows can *serve* the socket transport (see :func:`socket_share_required`),
114-
but stdio is what has years of exercise there while sharing is new, so it
115-
stays the default until the share path has CI behind it.
116-
``transport=socket`` opts in.
117-
"""
118-
if socket_share_required():
119-
return "stdio"
120-
return "socket" if socket_handoff_available() else "stdio"
121-
122-
123-
def resolve_transport(
124-
spec: Any, *, available: bool = True, default: str | None = None
125-
) -> str:
126-
"""The transport for ``spec``: explicit if given, else the default.
110+
def resolve_transport(spec: Any, *, available: bool = True) -> str:
111+
"""The transport for ``spec``: explicit if given, else the best available.
127112
128113
``available`` is the caller's capability for *its* kind of gateway --
129114
:func:`socket_handoff_available` for a worker we spawn,
130115
:func:`ssh_dialback_available` for one that has to reach back to us.
131116
Asking for a transport that cannot work is an error at makegateway time,
132-
rather than a hang once nobody connects. ``default`` decouples "can
133-
work" from "is what you get by default".
117+
rather than a hang once nobody connects.
134118
"""
135119
requested: str | None = getattr(spec, "transport", None)
136120
if requested is None:
137-
if default is not None:
138-
return default
139121
return "socket" if available else "stdio"
140122
if requested not in TRANSPORTS:
141123
raise ValueError(f"unknown transport {requested!r} (known: {list(TRANSPORTS)})")

src/execnet/_trio_gateway.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,9 +1102,7 @@ async def connect_popen_worker(spec: Any) -> tuple[ByteStream, trio.Process]:
11021102
from . import _provision
11031103

11041104
transport = _provision.resolve_transport(
1105-
spec,
1106-
available=_provision.socket_handoff_available(),
1107-
default=_provision.default_spawn_transport(),
1105+
spec, available=_provision.socket_handoff_available()
11081106
)
11091107
if transport == "stdio":
11101108
return await connect_command_worker(popen_worker_argv(spec))

testing/test_cli.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -217,15 +217,18 @@ def test_no_config_source_is_an_error(self) -> None:
217217

218218

219219
class TestTransportSelection:
220-
def test_defaults_per_platform(self) -> None:
220+
def test_a_spawned_worker_defaults_to_the_socket_transport(self) -> None:
221+
# every platform now: POSIX hands over the fd, Windows duplicates the
222+
# socket with share(). Only a host that can do neither gets stdio.
221223
spec = execnet.XSpec("popen")
222-
expected = "stdio" if sys.platform.startswith("win") else "socket"
224+
expected = "socket" if _provision.socket_handoff_available() else "stdio"
223225
assert (
224226
_provision.resolve_transport(
225-
spec, default=_provision.default_spawn_transport()
227+
spec, available=_provision.socket_handoff_available()
226228
)
227229
== expected
228230
)
231+
assert expected == "socket"
229232

230233
def test_explicit_wins(self) -> None:
231234
assert _provision.resolve_transport(
@@ -252,15 +255,12 @@ def test_asking_for_an_impossible_transport_is_an_error(self) -> None:
252255
execnet.XSpec("ssh=host//transport=socket"), available=False
253256
)
254257

255-
def test_windows_can_serve_the_socket_transport_but_does_not_default_to_it(
256-
self,
257-
) -> None:
258-
# sharing is new; stdio is what has years of Windows behind it
258+
def test_windows_hands_a_socket_over_by_duplicating_it(self) -> None:
259+
# `subprocess` refuses pass_fds there, so the capability comes from
260+
# socket.share() instead -- including on PyPy, once the socket is
261+
# handed over as a socket rather than rebuilt from its handle
259262
if _provision.socket_share_required():
260263
assert _provision.socket_handoff_available()
261-
assert _provision.default_spawn_transport() == "stdio"
262-
else:
263-
assert _provision.default_spawn_transport() == "socket"
264264

265265
def test_ssh_cannot_dial_back_on_windows(self) -> None:
266266
# no AF_UNIX in CPython there, and Win32-OpenSSH cannot -R a unix socket
@@ -279,16 +279,22 @@ def test_socket_transport_roundtrip(self) -> None:
279279
finally:
280280
group.terminate(timeout=5.0)
281281

282-
@posix_only
283282
def test_socket_transport_keeps_the_protocol_off_stdio(self) -> None:
283+
# whichever handoff this platform has, the point is the same: the
284+
# protocol is named on the command line, so it is not fd 0/1
285+
expected = (
286+
"--protocol-share"
287+
if _provision.socket_share_required()
288+
else "--protocol-fd"
289+
)
284290
group = execnet.Group()
285291
try:
286292
gateway = group.makegateway("popen")
287293
channel = gateway.remote_exec(
288294
"import sys; channel.send(sys.argv)",
289295
)
290296
argv = channel.receive(TESTTIMEOUT)
291-
assert "--protocol-fd" in argv
297+
assert expected in argv
292298
finally:
293299
group.terminate(timeout=5.0)
294300

@@ -305,7 +311,6 @@ def test_stdio_transport_still_works(self) -> None:
305311
class TestWorkerStdio:
306312
"""Whose stdio is it? The code the worker runs, unless told otherwise."""
307313

308-
@posix_only
309314
def test_socket_transport_inherits_stdio(self, capfd) -> None:
310315
group = execnet.Group()
311316
try:

testing/test_execmodel_trio.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def test_single_thread_on_main(self, trio_gw: Gateway) -> None:
6868
# async form on Windows, so those reads and writes run in the thread
6969
# pool. Only the socket transport is genuinely single-threaded.
7070
transport = _provision.resolve_transport(
71-
trio_gw.spec, default=_provision.default_spawn_transport()
71+
trio_gw.spec, available=_provision.socket_handoff_available()
7272
)
7373
if transport == "socket":
7474
assert active == 1

0 commit comments

Comments
 (0)