Skip to content

Rework execnet onto a trio-native core (sync facade, uv-provisioned workers, execnet.trio) - #422

Draft
RonnyPfannschmidt wants to merge 103 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:feat/trio-host-thread-io
Draft

Rework execnet onto a trio-native core (sync facade, uv-provisioned workers, execnet.trio)#422
RonnyPfannschmidt wants to merge 103 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:feat/trio-host-thread-io

Conversation

@RonnyPfannschmidt

@RonnyPfannschmidt RonnyPfannschmidt commented Jul 26, 2026

Copy link
Copy Markdown
Member

What this is

A ground-up rework of execnet onto Trio, replacing the thread-per-gateway receiver/writer architecture and the source-shipping bootstrap. This is execnet 3.0: the launch contract, the default transport and the ownership of a worker's stdio all change.

The blocking API is preserved as a facade over the new core, and released pytest-xdist keeps working against it unmodified — that is a hard constraint on this branch, checked in CI.

One protocol engine, four surfaces

AsyncGateway (_trio_gateway.py) is the single protocol engine: one serve task per gateway running a reader (stream → sans-IO FrameDecoder → dispatch) and a writer draining an outbound queue. Everything else layers on it. There is now one namespace per concurrency library you drive execnet from:

namespace what it is
execnet / execnet.sync today's blocking API, unchanged; the top level aliases into sync
execnet.trio trio-native AsyncGroup/AsyncGateway/AsyncChannel, awaited inside your own trio.run — no host thread
execnet.aio the same surface for asyncio, bridged per call onto the host loop
execnet.gevent the sync surface, with waits that park a greenlet rather than a thread

trio/aio/gevent load lazily, so import execnet still does not import an event loop. The blocking surfaces run their IO on a Host — one thread, one Trio loop, shared per process — and a blocking call made from inside a running event loop now raises and names the surface you wanted.

Underneath the serialized channel sits a two-level model: RawChannel carries id-routed verbatim byte payloads, which is what makes the via= tunnel frame-native instead of double-framed.

No source shipping, and a real launch contract

Nothing is bootstrapped over the wire. Workers import an installed execnet + trio, launched through a CLI that is now the contract between coordinator and worker:

execnet worker  --protocol-stdio | --protocol-fd FD[,FD]
                | --protocol-connect ADDR | --protocol-listen ADDR
                | --protocol-share
                --config JSON | --config-fd FD | --config-file PATH
                --stdin/--stdout/--stderr DISPOSITION
execnet server  [HOST:PORT] [--once]
execnet info

Foreign interpreters (python=) and ssh/vagrant remotes are provisioned on demand via uv; a dev coordinator builds and ships a wheel, cached remotely. execnet info answers JSON, so provisioning learns a remote's version before connecting.

The protocol is no longer the worker's stdin/stdout

A new transport=socket|stdio spec key selects, and socket is the default for every worker execnet spawns: an inherited socketpair on POSIX, a socket duplicated with socket.share() on Windows, and an ssh -R-forwarded unix socket the worker dials back on for ssh.

Two things fall out of that, both of them user-visible fixes:

  • A worker's stdio belongs to the code it runs. It used to be pointed at the null device, so a remote print() went nowhere at all. New stdin=/stdout=/stderr= spec keys override.
  • The worker config no longer travels in a remote argv, which closes an exposure: it carries env: values, and ps is readable by every user on the host.

Windows is supported and tested for the first time; ssh there stays on stdio, because CPython has never exposed AF_UNIX on Windows and Win32-OpenSSH has no StreamLocal forwarding.

Worker profiles

execmodel= is now spelled profile= (the old spelling stays as a permanent alias) and selects where exec'd code runs relative to the worker's protocol loop:

profile exec'd code runs
thread (default) the classic hybrid: the first remote_exec claims the worker's main thread, further ones overflow to pool threads
trio (new) async sources as tasks — one thread in the whole worker
gevent (revived) a greenlet per remote_exec on a main-thread hub

main_thread_only is deprecated and aliases to thread, which already gives the first remote_exec the real main thread — the GUI/signal property it existed for.

pytest-xdist

Released xdist must drive a 3.0 coordinator with no changes on its side, so the deprecated shims it reaches for (execnet.gateway_base.ExecModel, execnet.dumps, Group(execmodel=...), the execmodel= spec key) ship in 3.0. They go later in the 3.x series, once consumers have released without them.

CI runs xdist's own test suite against this execnet, in a pinned release variant that blocks and a floating default-branch variant that warns. Doing that for the first time found 16 real regressions our own suite structurally could not see — it uses xdist as a tool, which exercises none of the crash-replacement or report-serialization paths. Both are green now, with one deselect (test_remote_inner_argv asserts sys.argv == ["-c"], which the no-source-shipping launch deliberately changed; it needs an xdist PR).

Breaking changes

  • Remote environments must have execnet installed, or be reachable by uv provisioning; the zero-install source bootstrap is gone.
  • Trio is a hard runtime dependency.
  • execnet.dump/load/loads and execnet.script.* are gone; can_send replaces probing with dumps/DumpError.
  • The pre-Trio module names (execnet.gateway_base, gateway, multi, rsync, rsync_remote, xspec) warn and forward; gateway_bootstrap, gateway_io and gateway_socket are simply gone.
  • A blocking call inside a running event loop raises instead of hanging; a killed worker is uniformly EOFError; worker stdio is no longer swallowed.
  • eventlet is removed. The EXECNET_TRIO_HOST escape hatch and the legacy bootstrap stack are gone.

Semantics deliberately kept

  • Sends from non-loop threads block until the frame reached the OS write (120s → OSError), so an abrupt os._exit cannot drop already-"sent" data.
  • remote_exec admission order == message arrival order.
  • Group.terminate(timeout) is bounded (~2× timeout) even when a kill sticks.
  • Blocking waits stay KeyboardInterrupt-interruptible.
  • Channel callbacks preserve per-channel order, and waitclose() still returns only after every callback including the endmarker — they now run off the loop thread, so a slow callback no longer blocks the reader.

Still open before release

Tracked in ROADMAP-3.0.md in the repo root (with HANDOFF.md for the current state and handoff-history.md for the record):

  • a neutral capability key in execnet info — the one item that cannot be changed after release, since it is a cross-version probe contract that currently names our engine;
  • small surface cleanups (deprecated names out of __all__, underscoring two engine methods on trio.AsyncGateway);
  • provisioning and workspaces, so the next pytest-xdist can stop hand-rolling deployment: uv-bootstrap a remote python with the project under test installed, rsync the tests, and get back a local→remote path mapping;
  • driving test runs across Kubernetes pods over the protocol — the same problem with a shorter-lived remote;
  • deferred: an anyio/asyncio core (the engine sticks to portable idioms — neutral ByteStream, sans-IO frame decoding — to keep that cheap).

Test status

uv run pytest testing/: 552 passed, 66 skipped, sequentially and under -n 12. pre-commit run -a clean. tox -e docs builds with -W and runs the documentation examples as doctests. ssh paths are covered by a real local harness (testing/test_ssh_local.py, asyncssh server + system ssh client).

🤖 Generated with Claude Code

RonnyPfannschmidt and others added 30 commits July 22, 2026 18:20
Move coordinator and worker framed protocol IO into dedicated Trio host
threads for local popen + import bootstrap, while keeping the sync
Channel/Gateway API and WorkerPool remote_exec. Adds a trio dependency;
disable with EXECNET_TRIO_HOST=0.

Co-authored-by: Cursor AI <ai@cursor.sh>
Co-authored-by: Cursor Grok 4.5 <grok@x.ai>
Replace WorkerPool on the Trio popen worker with TrioWorkerExec:
thread-model tasks run via trio.to_thread, main_thread_only hands off
to the process main thread. Also wake the protocol writer with a Trio
Event instead of blocking to_thread queue.get.

Co-authored-by: Cursor AI <ai@cursor.sh>
Co-authored-by: Cursor Grok 4.5 <grok@x.ai>
Remove the EventletExecModel and GeventExecModel backends and their
get_execmodel branches, leaving only the stdlib thread and
main_thread_only models. Narrow the test execmodel fixture, drop the
gevent test dependency and the eventlet/gevent mypy overrides, and
scrub the docs of the removed backends.

This is phase 1 of moving execnet onto Trio: the ExecModel surface is
kept intact for now and collapsed further once Trio drives IO on both
sides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Launch the Trio popen worker as `python -m execnet._trio_worker
<id> <execmodel> <version>` instead of sending bootstrap source over
the wire. The worker imports the installed execnet + trio, writes the
`1` handshake after adopting its stdio fds, and serves; the coordinator
just waits for the handshake.

A rough major/minor version check warns on a real execnet mismatch
between coordinator and worker while tolerating patch-level drift.
Drops the import-bootstrap source send and the importdir/PYTHONPATH
plumbing (no more uninstalled running). Foreign-python and remote
transports stay on the legacy path pending the uv-provisioned bootstrap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route `popen//python=<interpreter>` through the Trio path. If the target
interpreter already imports execnet + trio, launch the worker module
directly on it (preserving sys.executable); otherwise provision an
ephemeral environment with uv and run the worker there.

New _provision.py builds the launch:
- coordinator_requirement(): `execnet==<ver>` for a released coordinator,
  else a wheel built from the editable source (located via PEP 610
  direct_url.json) and cached keyed by version. The wheel path is read
  from uv's "Successfully built" output rather than reconstructed, since
  the build-time version can differ from the import-time one.
- uv_run_argv(): `uv run --no-project [--python X] --with <req> python
  -u -m execnet._trio_worker ...`; trio comes in transitively.
- target_has_execnet(): cached probe deciding direct vs provisioned.

Falls back to the legacy source-copy path when the target lacks execnet
and uv is unavailable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an in-process asyncssh server (asyncio-loop thread) with binary-safe
command passthrough so the ssh transport can be tested against the system
ssh client without an external host. Committed, intentionally-insecure
ed25519 test keys back it (see testing/sshkeys/README.md); the fixture
copies the client key to a 0600 temp file since git does not preserve it.
asyncssh joins the testing extra. The initial test exercises the existing
legacy ssh path, validating the harness before the Trio ssh transport.

Also make mypy green at the source instead of casting at call sites:
TrioHost.call is now generic (Callable[..., Awaitable[T]] -> T) and
makegateway_popen_trio returns Gateway. Drop the stale types-gevent from
the mypy hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route ssh gateways through the Trio host: `ssh -C [-F cfg] <host>
'<uv worker command>'` spawns the uv-provisioned worker module on the
remote, then the coordinator does the usual b"1" handshake and attaches
a Trio session. HostNotFound is raised when ssh exits 255.

The popen and ssh factories now share _open_trio_gateway (spawn +
handshake + attach); ssh_trio_args builds the shell-quoted remote worker
command.

test_ssh_roundtrip is parametrized over the trio and legacy paths against
the in-process asyncssh server. test_sshconfig_config_parsing (white-box
over legacy Popen2IOMaster) is pinned to EXECNET_TRIO_HOST=0, with a new
ssh_trio_args test covering -F on the Trio path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dev coordinator's wheel is not on the remote filesystem, so the ssh
remote command becomes a POSIX-sh prelude that reads the wheel bytes from
stdin (`head -c N` into a temp dir) and execs uv against it. The
coordinator streams those bytes as a preamble before the Message
protocol; _open_trio_gateway grew a `preamble` argument. Released
coordinators still use `uv run --with execnet==<ver>` with no shipping.

Also collapse the worker CLI contract: id, execmodel and coordinator
version now travel as a single JSON argument (_provision.worker_cli_arg)
consumed by _trio_worker._main, instead of scattered positional args
built in three launchers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose the socket server as a `[project.scripts]` entry point so it can
be started directly, e.g. provisioned anywhere with
`uvx --from execnet execnet-socketserver :8888`. Refactor the __main__
block into a main() with an argparse CLI (hostport + --once), and thread
execmodel explicitly through startserver/exec_from_one_connection instead
of relying on a module global (which main()'s local scope broke).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the direct `socket=host:port` transport onto the Trio host:

- The socketserver becomes a Trio TCP listener that spawns a
  `python -m execnet._trio_worker --socket-fd N` subprocess per
  connection (passing the accepted socket by fd) instead of exec'ing
  sent source inline.
- The worker gains a socket serve mode: it adopts an inherited socket fd
  into a Trio SocketStream and serves the Message protocol over it. The
  worker CLI now always carries its config as args, so a future popen
  socketpair can reuse the same path instead of hijacking stdio.
- The coordinator connects a Trio TCP stream, waits for the b"1"
  handshake, and attaches a Trio session (no local process). A failed
  connect raises HostNotFound.

The worker serve setup is refactored into shared _build_worker_gateway /
_run_worker helpers. `installvia` still uses the legacy path for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Since execnet is now always installed on both sides, replace the
remote_exec source-shipping used by `socket//installvia=<gw>` with a
first-class protocol message. The coordinator sends GATEWAY_START_SOCKET
(with the bind host) on a request channel; the via gateway's Trio host
binds an ephemeral port, replies with the (host, port) on that channel,
and serves the one connection by spawning a worker subprocess. The
coordinator then connects to it over the Trio socket path.

This makes the inline-exec socketserver dead, so drop it: the
`execnet-socketserver` script is now only the Trio server + CLI, and the
Windows service wrapper launches that. The legacy `__channelexec__`
mode, `bind_and_listen`, `startserver`, and the inline `exec` are gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With direct socket and installvia both on the Trio path, the legacy
socket coordinator is dead. Delete gateway_socket.py (SocketIO,
create_io, start_via), drop bootstrap_socket and the socket branch from
gateway_bootstrap.bootstrap, and remove the now-unreachable
`elif spec.socket:` arm from Group.makegateway. All socket gateways now
go through _trio_host.makegateway_socket_trio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the common `popen//via=<master>` proxy transport onto the Trio host
with a new protocol message instead of remote_exec'ing gateway_io source.

The coordinator sends GATEWAY_START_POPEN (with the sub worker config) on
a request channel; the master's Trio host spawns a
`python -m execnet._trio_worker` sub-worker and relays its Message
protocol raw over that channel (stdin<-channel, stdout->channel). The
coordinator wraps the channel as ChannelByteIO and runs a normal Trio
session over it.

ChannelByteIO is marked an interim hack: it tunnels sub frames as
CHANNEL_DATA (double-framing) and should become a proper relayed
transport. ssh/foreign-python via sub-gateways still use the legacy path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generalize the via relay message from popen-only to a spawn-request dict
carrying the sub-spec essentials plus provisioning material: a released
coordinator sends a pip requirement, a dev coordinator ships its wheel
for the master to materialize into the local wheel cache.  The master
resolves the launch locally (direct module, uv-provisioned python=, or
ssh with the wheel streamed as stdin preamble), so ssh= and python= sub
specs now run on the Trio path.

Also route ssh=...//via=... through the via path instead of opening a
direct ssh connection, and close the request channel with an error on
spawn/relay failure instead of crashing the host nursery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vagrant_ssh= now launches the uv-provisioned worker through
`vagrant ssh <machine> -- -C <command>` (mirroring the ssh argv), both
as a direct gateway and as a via sub-gateway through GATEWAY_START_SUB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Trio host is now the only IO path.  Delete gateway_io.py (ProxyIO,
Popen2IOMaster, source bootstrap lines) and gateway_bootstrap.py, the
Popen2IO sync pipe IO, the thread receiver, and WorkerGateway.serve();
makegateway dispatches directly on the spec.  shell_split_path moves to
_provision, and HostNotFound moves to gateway_base and now subclasses
ConnectionError so generic OSError handling catches unreachable hosts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase B.1+B.2 of the Trio port:

- add gateway_base.FrameDecoder, an incremental sans-IO decoder for the
  9-byte-header Message framing (feed arbitrary chunks, complete
  messages come out; close() flags mid-frame EOF)
- replace the hand-rolled AsyncByteIO wrappers (ProcessStreamsIO,
  FdStreamsIO, SocketStreamIO) with trio.StapledStream / plain
  trio.SocketStream behind a neutral ByteStream protocol
  (send_all/receive_some/send_eof/aclose) that a future anyio backend
  can satisfy structurally; the via tunnel keeps its interim bridge as
  ChannelByteStream
- ProtocolSession's reader becomes the uniform receive_some+feed loop;
  exact reads survive only as the one-byte handshake ack
- route worker exec requests through a single FIFO pump task: batched
  frame decoding removed the per-message awaits that had accidentally
  serialized main_thread_only exec admission, so admission now happens
  explicitly in message-arrival order instead of racing tasks
- give pre-commit's mypy the trio dependency so trio types are real;
  drop now-redundant casts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase B.3 of the Trio port: one portal module replaces the three ad-hoc
cross-thread bridges.

- new execnet/portal.py: LoopPortal (trio token holder with
  run/run_sync/post/is_loop_thread; post = run_sync_soon, strict FIFO)
  and SyncReceiver (loop-to-thread queue whose get() stays
  KeyboardInterrupt-interruptible on the main thread)
- TrioHost owns a LoopPortal; call/call_sync/is_host_thread delegate
- ProtocolSession's outbound queue becomes an unbounded trio memory
  channel; every send is posted through the portal so loop callbacks
  and foreign threads share one FIFO, and the writer task is a plain
  async-for -- the recreated-Event wake dance is gone. Blocking and
  close semantics are unchanged: non-loop threads still wait for the
  OS write (120s timeout), closed sends still raise OSError, and a
  finished loop maps trio.RunFinishedError to the same OSError.
- TrioWorkerExec's main-thread exec handoff uses SyncReceiver

Because each end only needs the other loop's token, the same primitive
will serve two-loop setups (facade host loop + user loop) in B.5/B.6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase B.4 starts the async-native inversion: a new _trio_gateway module
hosts AsyncGateway, whose single serve task reads the framed Message
protocol off a ByteStream (receive_some + sans-IO FrameDecoder) and
dispatches inline -- no receiver thread, no receive lock -- plus a writer
task draining an unbounded outbound queue.

RawChannel is the low-level half of the two-level channel model:
id-routed raw byte payloads with the sync Channel close semantics
(CHANNEL_CLOSE both ways, CHANNEL_LAST_MESSAGE as write-EOF leaving the
peer sendonly, CHANNEL_CLOSE_ERROR surfacing as RemoteError).  Errors
keep the execnet contract -- OSError on closed sends, EOFError/RemoteError
on receive -- so no trio exception types leak into the API.

The ByteStream protocol and RECEIVE_CHUNK move here from _trio_host so
the async core sits at the bottom of the dependency stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The high level of the two-level channel model: AsyncChannel wraps a
RawChannel with dumps/loads per item, per-channel strconfig (RECONFIGURE
travels the wire, both ends coerce on load), async iteration, receive
timeouts via trio.fail_after surfacing execnet's TimeoutError, and
wait_closed mirroring the sync waitclose contract (reraise RemoteError).

Channel objects serialize over the wire: a duck-typed save_AsyncChannel
emits the existing CHANNEL opcode and AsyncGateway grows a factory
adapter the Unserializer resolves ids through, so channels received
inside items attach to the local gateway like sync channels do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o run

open_popen_gateway spawns a _trio_worker subprocess, does the handshake,
and serves an AsyncGateway directly in the caller's nursery -- the first
end-to-end trio-native path with no host thread.  AsyncGateway grows
remote_exec accepting the same source kinds (string / pure function /
module) as the sync API; exec-finish close, RemoteError propagation, and
concurrent execs all flow through the raw/serialized channel layers.

Source normalization moves to _exec_source (shared by both coordinators;
gateway.py re-exports the old names for its tests), and the transport
helpers (staple_*, handshake, popen argv) move down into _trio_gateway
so the async core has no dependency on the sync host machinery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AsyncGroup is the trio-native group: an async context manager whose
nursery serves every makegateway() as a child task.  Leaving the block
terminates all gateways concurrently with the safe_terminate contract --
GATEWAY_TERMINATE plus a timeout grace, then kill, bounded at roughly
twice the timeout even when a kill sticks (issues pytest-dev#43/pytest-dev#221) -- with the
cleanup shielded so external cancellation cannot leak workers.

open_popen_gateway becomes a thin single-gateway AsyncGroup wrapper, so
the popen integration tests now exercise the group termination path too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The via transport no longer double-frames.  ChannelFactory grows a
raw-receiver registry (CHANNEL_DATA payloads for registered ids route
verbatim, no serialization) plus allocate_id, giving the sync gateways
the low-level half of the two-level channel model.

The master relay forwards the sub-worker's ready byte alone and then
runs its stdout through a FrameDecoder, sending exactly one whole
sub-protocol frame per CHANNEL_DATA -- and writes coordinator payloads
(one frame each, by writer construction) straight to the sub's stdin.

Coordinator ends of the tunnel match: RawTunnelStream (sync master,
replacing the interim ChannelByteStream hack) and RawChannelStream
(async master, wrapping a RawChannel as a ByteStream).  AsyncGroup
accepts via= specs relayed through a group member, and terminates
tunneled gateways before their masters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sync coordinator and worker now run their protocol IO on the B.4
async core instead of the parallel ProtocolSession implementation:

- AsyncGateway grows per-frame write acknowledgements (outbound queue
  items carry an optional on_written callback; the writer fails pending
  frames on shutdown instead of stranding their senders), plus a
  _finalize hook for subclass shutdown work.
- AsyncGroup learns every transport (ssh=, vagrant_ssh=, socket= with
  installvia=, alongside popen/python=/via=), an overridable gateway
  factory, per-process reaper tasks, and remoteaddress stamping.
- SyncBridgeGateway subclasses AsyncGateway to dispatch messages into
  the classic sync Message handlers under the receive lock; it keeps the
  xdist invariant that non-loop-thread sends block until the OS write
  (120s -> OSError) while loop-thread sends only enqueue, all in one
  portal-posted FIFO.  ProtocolSession is gone; the worker serves on the
  same bridge.
- multi.Group owns a FacadeAsyncGroup task on its TrioHost: makegateway
  delegates to AsyncGroup.makegateway, and terminate keeps the
  exit()/join() member contract while the bounded GATEWAY_TERMINATE +
  grace + kill shutdown runs through AsyncGroup.terminate.
- Channel.__del__ posts its close message through the portal without
  waiting, so GC never blocks on a dying loop.
- RawTunnelStream.aclose now feeds EOF to its own reader; previously a
  terminated via bridge could wait forever on a reader that no longer
  had a registered raw receiver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cnet.portal

execnet.sync re-exports the blocking facade; the top-level execnet.*
names now alias into it.  execnet.trio exposes the async-native core
(AsyncGroup/AsyncGateway/AsyncChannel, raw channels, stream helpers,
shared serialization + errors) for use inside the caller's own trio.run.
execnet.portal (LoopPortal/SyncReceiver) is the communicating layer both
build on.  The trio and portal modules load lazily via module __getattr__
so plain `import execnet` still does not import the trio event loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The worker created its SyncBridgeGateway inside host.call and only
attached it to the sync gateway afterwards on the main thread.  A
coordinator message arriving in that window (STATUS, CHANNEL_EXEC as
the first action on a fresh gateway) dispatched into a gateway whose
_trio_session was still None, so the reply fell through to the sync IO
stub and killed the session -- the whole test suite under pytest -n 12
showed this as freshly created gateways being dead on arrival.  The
race predates the B.5 facade (ProtocolSession had the same window).

SyncBridgeGateway now attaches itself in __init__, before its serve
task can dispatch anything, and the redundant attach calls at the two
construction sites are gone.

Also drop the apipkg-era unknown-attribute test from the namespace
tests -- unknown attributes are plain Python module behaviour now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RonnyPfannschmidt and others added 30 commits July 31, 2026 05:32
With the hang gone, Windows ran the whole suite for the first time: 522
passed, 4 failed.  Three of the four are bugs that were always there and
that Linux hid.

`execnet server :0` reported a port nothing was listening on.  A wildcard
bind with an ephemeral port gives each address family its *own* random
port -- trio documents this -- and only the first was reported.  Which
family comes first is platform-dependent, so Linux reported the IPv4 port
a `127.0.0.1` client could reach and Windows reported the IPv6 one.  All
families now share the reported port, and a test pins it rather than
relying on the ordering that made this pass here.

test_basics wrote its generated check script with `write_text()`, i.e. in
the locale encoding, and Python reads source as utf-8 (PEP 3120).  A
single em-dash in the concatenated `_message` source was enough to make
the file unreadable wherever the locale is not utf-8.

The fourth was mine: `test_single_thread_on_main` asked
`resolve_transport` what the transport was without passing the default,
so on Windows it was told "socket" while the worker actually ran on
stdio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ndows)

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>
The share probe called `share()` and ignored what came back, so PyPy on
Windows passed it and still failed for real: the coordinator's half is
`share()`, the worker's is `fromshare()`, and either can be the one that
does not work.  Sharing to our own pid is what makes testing both cheap
-- the blob we produce is one we are entitled to rebuild, so the round
trip needs no second process.  An empty blob now also counts as failure.

test__rinfo raced: `receive()` returns when the *send* arrives and the
`os.chdir('..')` runs after it, so the following `_rinfo(update=True)`
could observe the old cwd.  It waits for the exec to finish now.  Not
caused by the share work -- it went red on ubuntu PyPy, which had been
green, because it was always a race and PyPy is slow enough to lose it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Narrowed by what *passed*: `popen//transport=socket` is green on Windows
PyPy, so `share()` to a child and `fromshare()` in it both work there.
Only the server-side path failed -- and the two differ in exactly one
thing.

The popen path shares a socket object it already has.  The server path
had only the accepted fd, and wrapped it with `socket.socket(fileno=fd)`,
which makes the constructor *detect* family/type/proto by querying the
handle.  Hand it what the caller already knows instead: trio's socket
carries all three, and skipping the detection removes the only step the
working path does not perform.

Also explains why no probe caught this -- both halves of the mechanism
work in-process on PyPy, so nothing short of the real accepted socket
could have shown it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit moved the Windows PyPy failure rather than fixing it:
the handshake EOFs are gone (52 -> 0) and the real exception finally
surfaced, one layer deeper -- in the worker rather than in the
coordinator that spawned it.

    _trio_worker.py:660 in open:      adopt_socket(sock.detach())
    _trio_host.py:83 in adopt_socket: socket.socket(fileno=socket_fd)
    OSError: [WinError 10014] The system detected an invalid pointer
             address in attempting to use a pointer argument in a call

Same defect, second site.  `ShareTransport.open` had a perfectly good
socket from `fromshare()`, detached it to a bare fd, and handed that to
`adopt_socket` -- which rebuilds a socket from the handle and so must
re-derive family/type/proto by querying it.  That derivation is what PyPy
on Windows cannot do to a handle that arrived through
`WSADuplicateSocket`.

`adopt_socket` now takes either, and the share transport passes the
socket it already has.  Reducing a socket to an integer and asking the
next layer to reconstitute it was never buying anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`via=`/`installvia=` gateways spawn and relay for the sub-worker they
create, which is what a coordinator does -- the old name said nothing
about the role and carried baggage besides.  Comments, docstrings, local
variables, test gateway ids and the proxy example all follow.

Where one sentence covers both parties, only the relaying one is named
"coordinator"; the process that requested it is left as "here" or "us",
since calling both by the same word is what made the old wording
tempting.

Also refreshes two doc examples that still expected "thread model" in a
gateway repr -- it has said "thread profile" since the `execmodel=` ->
`profile=` rename, so those doctests were already stale.  The
`set_execmodel` section in basics.rst is stale for the same reason and
wants a rewrite of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Fallout from defaulting Windows to the socket transport: two
killed-worker tests went red on all six Windows jobs with

    BrokenResourceError('socket connection broken: [WinError 10054] An
    existing connection was forcibly closed by the remote host')

where they expected EOFError.  A peer that dies abruptly *resets* a
socket, while a pipe just reaches EOF -- the same event, reported two
ways, and until now the transport decided which.  Callers test for
EOFError and an endmarker callback has to fire either way, so the reader
maps a broken read onto EOFError and keeps the original as __cause__.

ClosedResourceError stays as it was: that is us closing, not the peer
going away.

This also fixes `socket=` gateways on POSIX, where a reset is equally
possible over TCP and nothing was making it look like EOF either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers what a fresh session needs: the CLI as launch contract, the
transport matrix and why ssh on Windows cannot leave stdio, the share
handoff and the socket-not-fd rule that took four CI rounds to find, the
latent bugs that only surfaced once Windows actually ran, and the failure
modes worth preserving.

Leads with the CI outage, because the most useful thing to know is that
every job reported green-looking failures while executing zero tests for
four days -- and therefore that any platform CI has not really exercised
should be assumed broken.

Open work is listed with the reasoning attached, including the two ideas
that were evaluated and *not* taken (a trampoline process) or not yet
taken (spawning the installvia worker as the listener), so neither gets
re-litigated from scratch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
doc/basics.rst still documented set_execmodel() and a threading-models
section that no longer exists.  It now covers the namespaces, the worker
profiles that replaced execmodels, the full spec-key list including
transport= and the stdio dispositions, the shared host thread, and the
execnet command line; doc/implnotes.rst is rewritten around the launch
contract, transports and provisioning rather than the source-shipping
bootstrap it still described.  New doc/api.rst is the namespace reference,
which also gives the four :mod: targets something to resolve to.

The examples said they were automatically tested.  They were not: a
pytest_plugins line in doc/example/conftest.py has made collecting that
directory an error since pytest 7, so nobody noticed the trace sample
predating trio, the callback notes describing the old receiver thread, or
that basics.rst never imported execnet.  They run again -- with the two
examples that need a reachable ssh account marked +SKIP -- and tox -e docs
runs both them and the -W sphinx build, from a new CI job.  The docs had
never been built in CI at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Takes inventory of what the four namespaces actually publish now that the
docs freeze it, records the delta since 2.1, and asks what would be
expensive to undo if the host thread ever ran something other than Trio.

The one time-sensitive finding: `execnet info` answers `"trio": <version>`
and `_provision.target_has_execnet` reads exactly that key to decide
whether an interpreter can host a worker directly.  Once 2.2 ships, every
coordinator asks every future worker a question that names our engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The front page opened with "Do not use in new projects" and a project
status section promising no further improvements, in a release that adds
four namespaces, socket transports, a CLI and a worker profile axis.
Being pytest-xdist's backend is still the compatibility bar every change
is held to -- that is worth saying, and is not the same as a warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five handoff documents had grown by accretion: each phase appended its own
file, superseded parts of the previous one in place, and left the reader to
work out which decisions still stood.  Three now: HANDOFF.md is the state
and the invariants, ROADMAP-3.0.md is what is left to do, and
handoff-history.md keeps the landed record -- including a table of
decisions that were made and then unmade, so they stay unmade.

The release framing they were missing: this branch ships as 3.0, not 2.2.
It changes the launch contract, the default transport and the ownership of
a worker's stdio, and parts of the tree already said "pre-3.0" while the
changelog said 2.2.0.

Two goals are written down for the first time.  Unmodified pytest-xdist
must keep working on 3.0, which makes the deprecated shims part of the
release rather than something to remove in it; they go later in 3.x, once
the consumers that need them have released without them.  And the next
xdist should be able to stop hand-rolling deployment: uv-bootstrap a remote
python with the project under test installed, rsync the tests, and get back
a local-to-remote path mapping.  Driving test runs across Kubernetes pods
over the protocol is the same problem with a shorter-lived remote, so the
transport and proxy options are recorded next to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A host is the loop a gateway's protocol IO runs on, so closing one ends
every connection it carried.  Two paths did not say so.

`Host.close()` left the object reusable, so the next `makegateway()` on a
group whose host went away started a *second* loop thread -- which none of
that group's gateways are attached to -- and then failed on the stale
FacadeAsyncGroup with "<AsyncGroup []> is not entered", leaking a thread
per attempt.  Closing is final now, and `_ensure_started` says which host
died and why the group cannot be reused.

`setcallback()` after the loop stopped went through `run_on_loop`'s inline
fallback, which is not valid for the consumer switch: it drained the
mailbox and set `_has_consumer` before reaching `start_soon`'s guard, so
the buffered items were lost, `receive()` answered "channel has receiver
callback", and `waitclose()` waited out its timeout for a consumer task
that could never exist.  It now refuses before touching the channel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he loop

Three ways a facade could reach a host that cannot serve it, none of which
ended in an error the caller could act on.

**A posted callback that raises took the whole loop with it.**  Trio turns
an exception from an entry-queue callback into a TrioInternalError and
tears the run down, so `TrioHost.start_soon`'s guard clause -- reached from
inside a `portal.post` by `call_pending` and by the aio bridge whenever a
call lost its race with shutdown -- killed every gateway in the process and
told the user to file a trio bug.  Both spawns now report through the
OneShot/future they already resolve every other failure through, and
"nothing posted through the portal may raise" is written down as an
invariant.

**Nothing survives os.fork(), but everything waited as if it might.**  The
host's loop thread is not duplicated into the child and the worker
connections belong to the parent -- yet the parent loop's trio token still
*accepts* work in the child, so `run_sync_soon` queued callbacks nobody
would ever run and `from_thread.run` waited for a reply nobody would send.
A child using an inherited group, gateway or channel (the module-level
`execnet.makegateway` among them) blocked forever.  LoopPortal and
BaseGateway now compare pids and raise ForkedResourceError, an OSError
subclass so `__del__` and `except OSError` cleanup keep working, distinct so
`_send` can let the real reason through instead of rewriting it as "cannot
send (already closed?)".  Recovery is the child's to make explicitly: ask
for the default host and you get a fresh one to build new groups on.  A
child no longer runs the parent's atexit cleanup either -- those gateways
are not its to terminate.

**Group.terminate() and Gateway.join() joined the event-loop guard.**  Both
block on the host with no bound worth waiting out, which is exactly the
stall the guard turns into an error naming `execnet.aio`.  An empty group
has nothing to block on, so cleaning one up from inside a loop stays fine.

Also drops what the IO object no longer does: `wait`/`kill` had no caller
left in the tree or in xdist (the async group owns the process handle), and
with them goes `SyncBridgeGateway.host_call`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There is a seam between the connect helpers and the group: each helper
kills its process if the handshake goes wrong, but once it returns, the
worker is running with nobody owning it until `_processes[gateway]` is
set a few lines later.  A failure in between -- a cancellation, there is
not much else -- left a worker no terminate() would ever reach.  Forcing
one (a `_make_gateway` that raises) leaves `running with PID ...` behind
on the old code; now the stream is closed and the process killed and
reaped, the same shielded, bounded cleanup the helpers already do.

Also, two smaller things found while reading the same paths:

- The sync `Group.terminate` holds coordinators back from the first exit
  pass, which reads like duplicated ordering (`AsyncGroup.terminate`
  already runs tunneled gateways first) but is not: `exit()` ends with
  close_write, and a tunneled gateway's termination frames still have to
  travel through that stream.  Comment says so now, code unchanged.
- The sync bridge's shutdown no longer hops to a thread to call a
  coordinator's `_terminate_execution`, which is a no-op.  Only a worker
  has an exec pool to shut down.

The receiver-callback pool is documented where it can bite: it is shared
and bounded, so callbacks that wait on each other can fill it and stall
every channel in the process.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cross-checking the host boundary against the things our own suite cannot
see -- xdist's suite, and gevent as gevent is actually deployed.

**A loop that dies at startup left the caller waiting 30s for a message
that named nothing.**  The loop comes up on a thread nobody is watching,
so `trio.run` raising went to the thread's excepthook while `start()` sat
on `_ready` until the timeout and then said "TrioHost failed to start".
The exception is captured and re-raised at the call site now.  A late
failure still goes the loud way -- there, the thread traceback is the only
report anyone gets.

**execnet.gevent does not work in a monkey-patched process, and said the
opposite.**  The host loop is a trio program in a side thread; trio wants
`select.epoll`, real sockets, a real thread and a real `SimpleQueue`, all
of which `gevent.monkey` replaces process-wide.  Verified in every
variant: `patch_all()` loses epoll, `select=False` gets EBADF on trio's
wakeup socketpair, `thread=False, socket=False, select=False` still hits
gevent's SimpleQueue and LoopExit.  Patching was never what made the
namespace work -- its waits park the calling greenlet because they wait on
a gevent primitive -- but the docs said "do that yourself, as early as
usual", which invites exactly the broken setup.  The docs now state the
constraint, the startup error names gevent when it is the cause, and the
roadmap carries the decision: honest limitation, fight the global patch
with `monkey.get_original`, or a transport that needs no in-process loop.

**The gevent facade took the blocking portal call after all.**  Every
management op on it is careful to park the greenlet rather than the hub --
except starting the group's async side, which went through `TrioHost.call`
and blocked the whole hub.  Short enough that the timing test stayed
green, which is how it survived; the new test forbids the method outright.
One `Group.host_call` now decides how this facade waits on the host, for
makegateway, terminate and the async-group start alike.

Also: `RemoteError.warn` can no longer raise.  It is a best-effort
diagnostic for a channel nobody kept, it can run on the loop and as late
as interpreter shutdown with stderr already closed -- and an exception
there ends the run for every gateway in the process.

The xdist contract is green throughout (195 passed, `test_remote_inner_argv`
deselected as documented).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gevent limitation recorded last commit left an open question: can the
host loop just ignore the monkey-patching?  Answered by experiment, both
halves, because the answer decides an engine question rather than a
docs one.

For trio: only behind a private stdlib.  The obvious route fails for
non-obvious reasons -- gevent's saved originals are not self-contained
(the real `socket.socketpair` still builds `socket.socket` from its own
patched namespace), a reference captured before patching does not help
(`threading._CRLock` is set to None *inside* the real module), rebinding
trio's module globals misses everything an executing class body captured,
and trio asserts on primitive identity where it matters most for us --
the entry queue, which is what the portal rides on.  Re-executing the
pure-Python stdlib against unpatched C primitives and importing trio
against *that* does work, verified through every path execnet needs, and
costs a second `threading` and `socket` in the process: a host thread the
application cannot see, and socket identity split in two while our own
API takes sockets from user code.

For asyncio: nothing.  gevent replaces `selectors.DefaultSelector` where
it deletes `select.epoll`, so a patched process hands asyncio a working
hub-backed selector, and asyncio asserts nothing about identity.
Verified on a fully patched process, every path, in both shapes -- on a
thread, and as a greenlet on the application's own hub with no host
thread at all.

Which turns "a non-Trio engine" from an internals port into something
with a user-visible payoff, so it is recorded there too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two ends are installed independently now that no source is shipped,
and the protocol is unversioned -- so a major/minor skew has no defined
behaviour to warn about.  `_check_version` raises SystemExit, before
apply_stdio touches the worker's stdio: that is the last moment a reason
can reach the user, since afterwards the coordinator only ever learns EOF.

Patch-level skew stays tolerated, and EXECNET_IGNORE_VERSION_SKEW=1 gets
the old warning back.  It is read from the config's env: values as well as
the process environment, because _apply_worker_setup runs after the check
-- so `popen//env:EXECNET_IGNORE_VERSION_SKEW=1` reaches it, which is the
only way a spec can set a remote worker's environment.

The check had no coverage at all; TestVersionSkew now pins both outcomes,
both override paths, and end to end that the worker exits with the reason
on stderr while the coordinator's socket sees EOF rather than a handshake.

Also adds --protocol-share to the changelog's launch-contract block, which
listed four of the five protocol flags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HANDOFF and the roadmap had drifted from what is actually there:

- the test count was 552; it is 574.  `tox -e docs` doctests all of doc/,
  not just doc/example/ -- so basics.rst is verified, while test_debug.rst
  and test_ssh_fileserver.rst have no doctests and nothing checks them.
- `execnet info` answers six keys, not five, and "version" is spelled
  "execnet".  Its `protocols` list has no reader anywhere and omits
  `share`, so a Windows remote understates itself -- recorded under
  roadmap item 1, which is where that payload gets settled.
- the file map was missing _trace.py, _gevent_support.py and __main__.py.
- roadmap section 5 "Stale wording" was itself stale: the ExecModel
  docstring and _shim.REMOVED_IN were both fixed already.  Same for the
  changelog, which is renumbered to 3.0.

Elsewhere: the README still advertised zero-install bootstrapping and
eventlet, which 3.0 reversed and killed respectively; and the deselect in
xdist-known-failures.txt justified itself with a launch command
(`python -m execnet._trio_worker`) that is not the launch contract, in the
file a maintainer reads to judge whether the deselect still holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… runs

Two failures found reviewing the branch, one behind the other.

A thread-shaped exec costs a thread of trio's default limiter, and so does
a main-thread exec (it parks there waiting for the main thread) -- so 60
concurrent remote_execs on one worker ran 40 and left the rest admitted,
waiting for a slot only a finishing exec could free.  From the coordinator
that is not a queue, it is a remote_exec that hung on a channel nobody will
answer, and remote_status compounded it by reporting numexecuting=60.
Admission is now bounded at half the budget -- callbacks and the worker's
own to_thread work need the other half -- and the request over the line is
refused with a RemoteError naming the limit.  remote_status grew
execcapacity to report it, None under profile=trio where execs are tasks.

Deadlocking that worker exposed the second one.  executetask closes the
channel when the source returns, which is how the coordinator learns it
finished; a connection that went away first makes that close raise, and the
OSError travelled out of the exec task into the worker's *root* nursery,
ending trio.run.  Since 3.0 the worker's stderr is the user's, so what they
got was an ExceptionGroup of 24 tracebacks in their terminal.  The close now
tolerates a dead connection -- there is nobody left to tell -- and the exec
task contains anything else, which is what every host.start_soon entry point
already did except this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`socket=`/`installvia=` accepted `profile=`, `chdir=`, `nice=` and `env:`,
validated them, and dropped them: that worker is spawned by the *server*,
which built the config itself and never saw the spec.  2.x applied them
through a post-bootstrap remote_exec for every gateway type, so this was
lost in the port -- quietly, which is the bad part.  `profile=trio` on a
socket spec got a thread worker and said nothing.

The config now travels over the connection, one JSON line ahead of the
protocol, read a byte at a time so the frames after it stay for the worker
that inherits the socket.  The accepting side is a trust boundary, so it
takes the keys a spec legitimately carries and none of its own -- the share
blob is bound to a pid only the server knows.  A peer that sends no config
line within ten seconds gets its connection closed instead of a worker, and
unlike a failed spawn that does not take the accept loop down: a port scan
should cost one closed socket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
safe_terminate had no caller in src/ since termination moved into
AsyncGroup._terminate_one -- only the deprecated-name map and three tests,
so the pytest-dev#43/pytest-dev#221 bound was being tested on a function nothing used.  Removed,
and the bound is now asserted on Group.terminate() against a worker that
ignores SIGINT and never returns from its exec.

AsyncGroup allocated ids as "gw%d" % len(self._gateways), over a list that
terminate() empties -- so gw0 could name two different workers in one
session, in its traces and in whatever the caller keyed on it.

Also records the two findings that are not fixes: execnet.aio can drop an
item when a receive is cancelled after the host already took it (the
docstring promised otherwise and now says what it can), and the channel has
no flow control at all -- 500 MiB lands in a non-consuming peer's memory in
0.33s with nothing pushing back.  The second wants HTTP/2-shaped windows
plus the reporting that makes a full window distinguishable from a hang,
and a credit field is cheaper to reserve before the protocol ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e had

CI caught the first one on every Linux runner: the skewed-version test
spelled an "impossible" coordinator version, 0.1.2, and a wheel built from
a checkout without tags is 0.1.dev1 -- which is exactly what CI installs.
The versions matched, the worker started and served, and the test blocked
on its output until it timed out.  The skew is derived from the real
version now, the way the unit tests around it already did.

The second was mine too, in the message I had just written: it points at
profile=gevent as the way past the exec cap, and profile=gevent was capped
right along with thread.  Waiting for an exec that runs elsewhere -- the
main thread, a greenlet -- parked a pool thread on a threading.Event, so
execs that cost no thread each held one anyway, and a gevent worker was
rationed to 20 concurrent greenlets.  That is a cap on the one thing the
profile is for.  The wait is a trio.Event woken from the exec's own thread
now, and capacity is the strategy's to declare: None where execs are tasks
or greenlets, half the thread budget where they are threads.

Also drops the last of the review's stale wording: Channel.send has not
blocked on a full queue since 2.1's write lock, and says what it does do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ots at the close

Windows CI, intermittently, on 3.14 and 3.15: a popen worker died before
its handshake with WSAENOTSOCK, which reached the coordinator as a reset
connection during makegateway.  socket.share() hands the child a *blob*,
and there is no socket on the other end until it calls fromshare() -- so
closing our copy the moment the spawn returns is a race the child loses.
The POSIX comment ("the child holds its own copy now") was true for
pass_fds and quietly wrong for share.  We now close after the handshake
there, which is the point where the child has provably adopted.

The server side has the same race and is not fixed: it cannot wait for a
handshake that goes to the coordinator, and holding the socket instead
would cost the coordinator its EOF when a worker dies.  Recorded with the
option it needs (a marker byte from the worker) in HANDOFF and the roadmap.

Second, from a flake in the exec-capacity test under -n 12, which turned
out to be the feature and not the test: the admission slot was released
when the exec *task* unwound, a moment after executetask had already sent
the channel close.  That close is exactly what tells a coordinator at
capacity it may send the next request, so waitclose() + remote_exec()
could be refused for a slot that was already free.  The release moved into
_close_finished, ahead of the close, and is idempotent so the task's
finally still covers whatever never got that far.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…plain itself

Holding the coordinator's copy of the socketpair until the handshake was a
guess at the Windows 3.14/3.15 failure -- the blob really is not a socket
until the child calls fromshare(), so closing early looked like the race.
CI says otherwise: WSAENOTSOCK still, and now a 20s hang on top, because a
worker that dies while we hold the pair open produces no EOF for anyone to
notice.  Reverted; the guess cost less than leaving it in would have.

What the round is worth keeping is the diagnosis: a worker that never
reaches its handshake now reports its exit status instead of whatever its
closed socket happened to look like, which is the one fact CI could not
tell us this time.

The Windows breakage itself is recorded in HANDOFF and the roadmap as
open, with the theory that has already been refuted, so the next attempt
starts from a bisect rather than from this one again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine of them, mine and older, handed to trio SocketStreams and then
dropped.  A dropped wrapper still owns its handle and closes it whenever
GC gets to it, which is a handle closing at an arbitrary later point in
the run -- the kind of thing that only ever shows up on Windows, and the
main suspect for the WSAENOTSOCK the share handoff started reporting
there today.  Hygiene either way: a test that opens sockets closes them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The matrix here stopped at 3.14 and setup-python had no allow-prereleases,
so a 3.15 job could only ever die at setup ("version '3.15' with
architecture 'x64' was not found") -- main has both and the PR runs
inherit them through the merge, which is why this only bites a direct
push to the branch.  That is not academic: the Windows failure being
chased is 3.14/3.15-only, and every probe pushed to a test-me-* branch was
silently testing neither of the two prerelease jobs properly.

Mirrors main: 3.15 in the matrix, allow-prereleases on the matrix job
only (docs and xdist pin a released 3.13), and the 3.15 classifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A released version should be tested as users get it; the flag is only
needed by the one entry that has no release yet.  Expression on the
matrix value, so 3.10-3.14 and pypy resolve exactly as before -- move it
to the next version when 3.15 ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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