Skip to content

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

Draft
RonnyPfannschmidt wants to merge 139 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 139 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 August 1, 2026 21:06
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Workers on a machine that shares no filesystem with the coordinator need
the project before they can run anything -- and because a worker *is* the
process that runs the tests, it has to already be inside the environment
the project was installed into.  So provisioning cannot be part of
starting a worker: it gets a gateway of its own, and the workers come
after it.

    bootstrap = group.makegateway("ssh=host")
    target = execnet.Deployment(project=".", roots=["testing"]).deploy(bootstrap)
    bootstrap.exit()
    worker = group.makegateway(f"ssh=host//{target.spec}")

Three steps in the one order that works: a frozen environment from the
project's own lockfile, so the remote resolves nothing; the artifact, a
wheel built here and installed there rather than a source tree; and
everything a test run needs that the wheel deliberately does not carry --
tests, conftest.py, fixture data.

Both halves travel over the gateway's own protocol -- the transfer is the
RSync that just became a service, the install is a GATEWAY_DEPLOY request
the worker serves -- so there is no second connection, no second set of
credentials, and nothing that assumes ssh.  That is what will let the same
code reach a pod.

Deployed reports where things landed and translates local paths to it: the
remote layout is a provisioning fact, and the caller knows only local
paths.  Deployments sharing a name share a workspace on the host, so the
second gateway to a machine finds the environment the first one built.

One trap this found, worth the invariant: a worker inherits its
coordinator's environment, and `uv pip install` honours VIRTUAL_ENV --
which a coordinator very often has set.  Without scrubbing it the project
lands in the *coordinator's* environment and the deployed one silently
lacks it, which is exactly how it first failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were built *into* the protocol core and driven from the calling
thread.  Seven core files named them -- two opcodes, a dispatch table, two
request helpers on Gateway -- so adding a service meant editing the
protocol, and moving one out of tree was impossible.  Meanwhile RSync.send
was a blocking loop over a queue.Queue and Deployment.deploy ran `uv build`
inline, which left the async surfaces with nothing and made a fan-out
across twenty hosts twenty deployments in a row.

The seam is now one generic GATEWAY_SERVICE opcode and a registry that
maps a service name to an import string, resolved when a request for it
arrives.  The core spells no feature name anywhere -- there is a test that
greps for it -- and an out-of-tree service is a register() call on both
ends rather than a patch.

execnet/_deploy/ is that layer.  Its driver is async and runs on the host,
so the blocking API is a facade that parks the way its surface parks, and
execnet.trio and execnet.aio get transfers and deployments for the first
time.  Targets are worked on concurrently: one wheel build, N tasks.

The transfer conversation is new, and simpler than the one it replaces:
one manifest instead of a message per directory node, then bodies in 1 MiB
chunks so a wheel does not spike memory by its own size on both ends.  The
size/mtime/digest check that makes re-transferring an unchanged tree nearly
free is kept, because that is the part that earns its keep.

`Group.host_call` becomes a free function in _trio_host: gateway creation,
termination and now transfers all need "run this on that host, parking the
way this facade parks", and only Group could express it.

The legacy RSync is untouched except that it asks through the same seam --
its conversation, its hooks and its receiver are as they were, and pytest
-xdist drives it unchanged.

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

The evaluation the last commit set up: everything xdist uses maps onto the
new transfer.  `filter` is the walk's filter, `_report_send_file` is the
progress hook -- still handed the sync Gateway, which is what xdist reads
`spec.chdir` off -- `delete` is per target, and `finishedcallback` fires
when that target's task ends.  So RSync is ~50 lines of adaptation over
the same driver as `execnet.transfer`, and the pre-3.0 conversation, its
receiver and its service registration are gone.

One thing did not survive, and it is the one nothing uses: `callback` gets
the gateway rather than a channel, and its "ack" fires when a file is sent
rather than when the far side confirms it -- the new protocol has no
per-file acknowledgement to hang that timing on.

Reimplementing it found a real bug in the new manifest: it rebased
*relative* symlinks onto the destination root.  Being relative is exactly
what makes a link survive the move, so rewriting one turns "my neighbour"
into a particular absolute path.  Only absolute links pointing inside the
tree are rebased now, which is what the old code did and what the symlink
tests were pinning.

xdist's own suite only runs in CI, so testing/test_rsync.py grows a local
stand-in for `HostRSync` -- the subclass, the overrides, the relative
destination resolved against the worker's chdir -- to find this out here
rather than there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`exec_capacity` takes half the worker's pool and says why in as many
words: the rest is for "the machinery that has to keep running while execs
are in flight".  Services are new machinery that was never added to that
sum, and they take threads from the same default limiter -- one per
concurrent request, held for the whole body.

Measured on a worker: 25 concurrent transfers held 25 of its 40 threads
and pushed a 5ms remote_exec out to 3.1 seconds.  This is not hypothetical
or hostile, either -- a deployment opens one transfer per directory root
at once, so it does this to its own worker.

Service bodies now share a limiter of a quarter of the pool, which leaves
exec's half intact and a quarter over for everything else.  Same load:
12 threads instead of 27.

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

The shape documented was "deploy, exit that gateway, connect again for
each worker".  The real one is one connection per machine: the gateway the
deployment ran through stays on, and the test workers are spawned `via=`
it, as its local children.

That ordering is also what keeps the two off each other -- the transfer is
finished with that host's loop before it starts relaying for anybody --
and it is what deploy_all is for at the next level up: one gateway per
machine, each of which then spawns its own workers.

It already worked; nothing pinned it, and the example pointed elsewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Host" meant three things at once: this class (a local OS thread running
trio), the remote machine a deployment lands on, and the network address in
`socket=HOST:PORT` / `HostNotFound`.  The collision was not academic -- a
user deploying to remote hosts hit "all gateways must be served by the same
execnet.Host", and `_deploy/__init__` used both senses in one sentence.

The class is the newcomer: it is 3.0-only and has never shipped, so it
moves rather than the vocabulary that predates it.  `engine` is already the
word the roadmap uses for the swappable core, and it stays honest if the
loop ever stops being a thread.

`Host` -> `ProtocolEngine`, `_host` -> `_engine`, `TrioHost` -> `TrioEngine`,
`host_call` -> `engine_call`, `Group(host=)`/`.host` -> `engine=`/`.engine`,
`default_host` -> `default_engine` (and out of `execnet.aio.__all__`: it read
as an asyncio concept when it is the opposite).  No shims -- none of these
names has been released.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_trio_host.py` was 1126 lines of two different things: the loop thread
itself, and everything that runs on it (the sync bridge, the facade group,
socket and via routing).  The loop moves to `_trio_engine.py`, where its
interface is small enough to read in one screen.

That is what makes the interface enforceable.  `aio` and `_multi` used to
reach into `TrioEngine._nursery` to attach their facade groups, and
`start_session` built a `SyncBridgeGateway` from inside the engine -- so
the engine both knew about the layer above it and let that layer start
tasks behind its back.  Now `start_task()` is the single door: a long-lived
task is something the engine knows it is running.  `start_session` becomes
a function in `_trio_host`, where the session it builds already lives.

`callback_limiter` -> `_limiter`, `is_engine_thread` -> `_on_engine_thread`,
`call_pending` -> `_call_pending`: all three only ever had callers inside
execnet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`close()` used to break every group on the engine and walk away.  The
docstring told you to terminate them first; nothing helped you do it, and
nothing noticed when you didn't -- so the workers were simply left behind,
because the only thing that could have reaped them was the loop that just
went away.

Closing now terminates them, and warns (`ActiveGroupsWarning`, a
UserWarning so it is not ignored by default) naming the gateways involved.
The warning is the point: terminating at close time is doing the caller's
job at the moment they can least act on a worker that will not go quietly.
`terminate()` is that job, split out -- it drains the groups and leaves the
engine usable, which is what a caller with somewhere to report to should
call.

Also: the thread not joining within the timeout is warned about rather than
returned from as though it had stopped; `close()`/`terminate()` from the
engine's own loop thread is refused instead of deadlocking; and the docstring
no longer claims closing is "a no-op when not running" when in fact it is
final for an idle engine too.

The `atexit` path terminates quietly -- by then there is nobody left to warn,
and the warning may not even be displayed, but reaping still matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`execnet.aio` had the only implementation of "run this trio coroutine on
the engine, wait for it on my own loop, and forward a cancel the other
way".  The trio facade needs the same thing, and the engine half of it is
identical -- what differs is only how the caller waits.

So the mechanism moves to `_bridge.py` as `EngineBridge` plus a `Carrier`
per caller surface, and the three subtleties travel with it as comments
rather than being re-derived: the cancel scope built before the task
exists, the entry-queue callback that must not raise, and the
cancelled-by-us case that posts nothing back.

The carriers are where the two surfaces are honestly different.
`asyncio.shield` delivers the CancelledError while the work continues;
a shielded trio scope makes the wait itself uncancellable.  Both are kept
as their own idiom rather than forced to match -- documented on the
carriers, since a caller reading `shield=True` deserves to know which one
they get.

`execnet.aio` behaviour is unchanged; its tests are the check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every surface but one put protocol IO on a shared engine.  `execnet.trio`
was the exception -- gateways as tasks in the caller's own nursery -- which
meant the users most likely to want isolation (a long-lived app whose loop
does other things) were the only ones who could not have it.

So there are two now, and they are a real choice rather than a rename:

* `execnet.raw_trio` is what `execnet.trio` was.  Your loop *is* the
  protocol loop: no thread hop, exact cancellation, structured concurrency
  over the gateways -- and a non-yielding step stalls protocol IO for all
  of them, an error in your task tree cancels gateways mid-protocol, and a
  gateway cannot outlive the `async with` that made it.
* `execnet.trio` runs the gateways on the shared `ProtocolEngine`, like
  `sync`, `gevent` and `aio` do.  A busy caller loop no longer stalls the
  protocol, gateways are handles rather than scoped resources, and one
  engine serves every surface a process uses at once.  Each operation
  costs a hop, and a cancelled `receive` has the same lost-item window
  `execnet.aio` documents.

The facade is deliberately a subset: `_open_raw_channel`, `open_channel`
and `_enqueue_frame` hand out channel ids from an unlocked per-gateway
counter that only works because one loop owns it, so they stay raw-only
(and are now underscored there, per ROADMAP item 3).

Two things fixed on the way:

* `TrioEngine.stop` posted its shutdown request with `portal.run_sync`,
  which *refuses* a caller that is itself inside a trio run -- and the
  refusal was swallowed.  Closing an engine from inside any async program
  left the thread running for the rest of the process.  It posts now.
* `aio.deploy_all` took the first gateway's bridge and assumed the rest
  matched; two engines produced a cross-run await instead of an error.
  Both facades now share the check the blocking surface always had.

`execnet.gevent` also gains `Deployment`/`Deployed`/`transfer`: the
plumbing to park a greenlet through a deployment was already there, the
names simply were not exported.

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

`execnet.gevent` shipped without `Deployment`, `Deployed` or `transfer`
while `_deploy._facade` already had a gevent parking path -- the plumbing
was there, the names were not, and no test could notice.  The surface
tables here are that hole closed: the verbs every namespace owes, the
engine names the engine-backed ones expose, and the two facades' public
member sets compared against each other and against `raw_trio`.

The subset is only credible written down.  `_open_raw_channel`,
`open_channel` and `_enqueue_frame` are asserted present on `raw_trio` and
absent from both facades, so "deliberately omitted" cannot decay into
"forgotten" in either direction.

`TestEngineDestruction` also splits from the new close policy.  Those tests
are about a loop that went away, and close() now terminates first -- which
made them race a killed worker's reset connection against a clean EOF.
They take the loop away directly now, and stay deterministic; what close()
does about live groups is `TestEngineShutdownContract`'s job.

Also fixes the pre-existing lint the tree was carrying (three `zip()`
without `strict=`, one nested `if`, one typo).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`testing/test_deploy.py` only ever drove the blocking `Deployment.deploy`.
`execnet.trio.deploy`, `execnet.aio.deploy` and `execnet.raw_trio.deploy`
had no test at all, and `deploy_all` with more than one target had never
run from anywhere -- so neither the concurrency its docstring promises nor
the same-engine check that guards it was covered by anything.

All four surfaces reach the same driver now, and the fan-out is exercised
with its refusals: no gateways, and gateways belonging to two engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reference gains `execnet.raw_trio` and a table for the choice between
it and `execnet.trio` -- gateway lifetime, cancellation, what a stalled
caller loop does, whose thread budget a transfer spends, and the cost per
call.  That table is the reason both exist, so it belongs where someone
choosing an import will read it.

`basics.rst` and `implnotes.rst` follow the rename and describe the close
contract: terminate your groups, or closing does it and warns.  The
implementation notes also record the two seams the refactor introduced --
`_trio_engine` is the loop and `_trio_host` is what runs on it, and
`start_task` is the only door to the root nursery -- and why `stop()`
posts its shutdown request instead of running it.

The 3.0 changelog entries are rewritten rather than appended to: none of
these names has shipped, so what it describes is simply what 3.0 is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bridge cancels the engine-side receive when the caller is cancelled,
but the two race: if the engine had already taken an item, the carrier
resolved with a value nobody was left to take, and it was dropped.  That
was the one place a facade behaved worse than `execnet.raw_trio`, and it
was documented as "do not cancel a receive whose item you still need".

The carrier now hands such a value to a `salvage` the call names, and
`AsyncChannel.receive` gives it a one-slot pushback that the next receive
drains first.  Whichever way the race goes, nothing is consumed: either the
cancel arrived before the engine took an item, or the item comes back in
order.  An unclaimed *error* is still dropped -- it describes the operation
the caller abandoned, and the next call raises its own.

Both orderings are covered deterministically in `testing/test_bridge.py`,
because neither is reachable by sleeping: an end-to-end attempt with a
0.5ms deadline took the cancel path 20 times out of 20 and would have
passed while proving nothing.  The end-to-end tests force the window
instead, by queuing the cancel ahead of the delivery on a FIFO entry queue.
Queuing it *behind* the delivery proves nothing either: setting the event
reschedules the waiting task, and trio will not deliver a cancel to a task
that already has a wakeup pending.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ProtocolEngine(backend="asyncio")` builds `AsyncioEngine` instead of
`TrioEngine`: same contract -- start, a portal, one door to the root task
scope, the group registry, a stop that joins -- and the same tests, which
are parametrized over both so the second implementation cannot drift from
the first quietly.

**The asyncio engine cannot host gateways yet**, and says so.  The protocol
core is still written against trio directly, so a group built on one is
refused with a sentence naming what is missing rather than failing
somewhere inside trio with a message about a nursery.  What this buys now
is that the seam is a tested boundary instead of an intention: the parts
below the core -- the loop, the portal, the task scope -- are proven to
have a second implementation.

Two things asyncio needed spelled out:

* `TaskGroup` has no `nursery.start()`.  `start_task` builds one with
  trio's semantics: a failure before the task reports ready goes to the
  starter and nowhere else, and only a failure afterwards reaches the group.
* Cancelling the root scope is a sentinel raised out of the `TaskGroup`
  body.  Filtering it back out needs `group.split(_Shutdown)`, not
  `subgroup(predicate)` -- a predicate is offered the *group* as well as
  its leaves, so `not isinstance(exc, _Shutdown)` matches the group itself
  and keeps everything.

Python 3.11 or newer, refused when the engine is built rather than at
start(): the version is a fact about the interpreter that nothing a caller
does later can change.  No backport -- 3.10 reaches end of life in October
2026 and keeps the trio engine until then.

Portals now raise a backend-neutral `LoopFinishedError`, so the dozen
callers that reach a loop through one stop naming `trio.RunFinishedError`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `raw_trio` comparison table claimed a cancelled `receive` loses an item
on the facades.  It no longer does, so the row says what is now true and
the difference between the two surfaces is one less thing.

The implementation notes gain the two findings behind that.  First, almost
nothing crosses a cancel: every bridge call is shielded except six, only on
the two async facades, and `receive(timeout=)` enforces its deadline
engine-side so it does not cross at all.  Second, of those crossings only
`receive` could lose anything, which is why salvage buys the whole
property -- cancellation *precision* is not load-bearing anywhere, and that
is what makes a backend with weaker cancellation an option.

The roadmap's "what pins us to Trio" is now measured rather than estimated:
the inventory of all 190-odd trio call sites, what each category maps to,
and the correction that the nineteen shielded-cleanup sites are the
cheapest part rather than the riskiest -- trio needs the shield because it
is level-triggered, asyncio does not because it is edge-triggered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
About a dozen names -- task scope, shielded, two deadlines, event,
limiter, unbounded queue, thread hop, checkpoint, and an exception set --
with an implementation on each async library.  Which one an object gets is
decided by the loop it is built in and captured once, so nothing pays for
the detection per operation, and the trio side returns trio's own objects
wherever it can.

Cancellation is the whole reason this can exist.  An audit of the core
found that *every* cancel scope in it is the shielded-cleanup idiom --
`with CancelScope(shield=True)`, sometimes with a `move_on_after(5)` --
and that the only `.cancel()` calls are whole-scope cancels.  There is no
nameable, level-triggered scope anywhere, which is the one thing asyncio
could not have provided.  So `shielded()` is a real scope on trio, where
level-triggering makes it mandatory, and a no-op on asyncio, where a
cancel is delivered once and the cleanup simply runs.

Two things needed building rather than mapping:

* `move_on_after`/`fail_after` as *synchronous* context managers, so a
  deadline reads the same on both.  `asyncio.timeout` is an async CM, but
  nothing it does needs to await; the delicate part is deciding whether an
  arriving cancellation was ours, and that follows CPython's own
  `asyncio.timeouts` -- remember the task's cancellation count on entry and
  only swallow one if `uncancel()` brings it back.  Pinned by a test that an
  outer cancel is not eaten by an inner deadline.
* `scope.start()`, which `TaskGroup` lacks, with trio's semantics for a
  failure before the task reports ready.

43 tests, run against both backends, asserting the cancellation difference
rather than smoothing over it.

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

`ByteStream` is four methods and trio's own stream types satisfy it
structurally; this is the other implementation.  asyncio hands out a reader
and a writer rather than one object, and reports failure as `OSError`
subclasses rather than a resource vocabulary, so each wrapper puts the pair
behind one object and translates what goes wrong into the words the core
catches.

Covers what the `popen` transport needs -- a connected socket, a process's
stdin/stdout pair, and a pair of fds -- which is the default transport and
the one every test uses.  TCP and unix listeners, for `socket=`,
`installvia=` and `ssh=`, are additive and not here yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Streams, processes and listeners behind the same names, so the transports
can build what they need without knowing which library is under them.
Trio's side is a thin forward to its own types; asyncio's is `_aio_io`,
which also gains socket-level TCP and unix listeners -- the core accepts
connections one at a time (a dial-back, a one-shot socket gateway) rather
than handing the loop a callback, and reads the bound address back off the
socket.

`default_thread_limiter()` becomes `thread_budget() -> int`.  It only ever
existed to size a share of the loop's thread pool, and handing back a
freshly built semaphore from something named "limiter" invited using it as
one, which would have silently unshared the bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_trio_gateway` -- gateways, channels, groups and every transport -- now
reaches concurrency and IO through `_async` alone.  All 86 trio call sites
are gone: nurseries became task scopes, `CancelScope(shield=True)` became
`shielded()`, memory channels became queues, and the stream and process
constructors became vocabulary methods.  Objects capture the vocabulary
once at construction, as `self._aio`, so the loop that built them decides
which library they use and nothing pays for the detection per operation.

The existing 843-test suite passes unchanged, which is the point: this is a
substitution, not a redesign.  Three things were not substitutions:

* `staple_fd_stream` becomes async.  Trio can wrap fds synchronously,
  asyncio needs `connect_read_pipe`/`connect_write_pipe` on the loop.
* `AsyncGroup` captures its vocabulary in `__aenter__` rather than
  `__init__`: it is constructed outside the loop it will run on.
* A task scope keeps its handle live until it has actually finished.  The
  first attempt nulled it on the way into `__aexit__`, which broke the ssh
  dial-back: that races an accept against the process exiting and cancels
  the scope *from a child*, during teardown.

`AsyncioProcess.stdin`/`.stdout` are byte streams rather than raw
reader/writer pairs, because the core writes a wheel to a process's stdin
with `send_all` and closes it with `aclose`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_trio_host` -- the sync bridge, the facade group, socket adoption and the
via relay -- goes through `_async` like the core does.  Its 26 sites were
the same substitutions, plus two groups that build their events in
`__init__` rather than `__aenter__`: `FacadeAsyncGroup` and
`_bridge.EngineGroup` are constructed *on* the engine loop, so they capture
their own vocabulary there.

Suite unchanged and green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The whole suite passes on both backends: 847 on trio, 846 on asyncio.

`_trio_worker`, `_trio_host`, `_deploy`, `_rsync` and `_services` follow the
core through `_async`, and the two worker entry points stop naming trio:
`run_loop(backend, main)` for a pure-async worker and `build_engine()` for
every other profile, both choosing the same way the coordinator does.

`pick_backend()` is that choice, and it is where the gevent limitation
turns into a feature: trio when it is installed, asyncio when
`gevent.monkey` has patched the modules trio's cross-thread machinery needs.
That is the one environment trio cannot run in and asyncio does not care
about, so `execnet.gevent` now works in a patched process instead of
refusing.  Resolved when the loop starts, not when the engine is built --
patching can happen in between.  An explicit `backend=` is still checked at
construction, and an explicit `backend="trio"` still refuses when patched.

Four things the port needed beyond substitution:

* a `cancel_scope()` in the vocabulary.  Every scope in the *core* is a
  shield or a deadline, but the bridge needs one it can aim: it cancels an
  engine-side operation when the caller awaiting it goes away.  On asyncio
  that is the task's own cancellation, so the scope is entered by the task
  it will cancel.
* `TrioWorkerExec` takes its vocabulary from the engine.  It is built on
  the main thread, not on the loop, so "which loop am I in" has no answer
  there.
* the asyncio inbox grew `__aiter__` and an awaitable `send`, which the
  writer loop and the via relay use.
* `open_tcp_stream` leaves a *connect* failure as the `OSError` it is.
  Translating it to a stream error lost `HostNotFound`: could-not-reach is
  not the same as the-stream-broke.

`ProtocolEngine._require_core_backend` is gone -- the core it guarded
against no longer exists.

One divergence remains, and is pinned rather than hidden: which error a
channel reports once its loop has been taken away without a shutdown.
`TestEngineDestruction` asserts trio's exact answer and now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing is required on Python 3.11+, where the asyncio engine runs
everything the trio one does.  Below that there is no `asyncio.TaskGroup`
and trio is the only engine, so it stays required -- an environment marker
rather than a second package, and the line goes away when 3.10 reaches end
of life in October 2026.

`execnet[trio]` asks for it anywhere, and it is preferred whenever it is
installed, so an existing install keeps the engine it has.  A bare
`pip install execnet` on 3.11+ now gets asyncio: that is the default for
most modern installs, which is why the whole suite is run against both
engines rather than treating asyncio as a fallback.

The testing extra keeps trio unconditionally -- it runs both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Making trio an extra was one line; making a trio-less install *run* was
five modules that imported it at the top.  `_portal`, `_bridge` and
`_deploy/serve` now import it where trio's own implementation needs it,
and `_trio_host` does not import `_trio_engine` at all -- every use was an
annotation, and it made the whole routing layer, which a worker loads for
its socket transport, need trio to be importable.

`execnet info` grows the neutral capability key this forced.  The probe
that decides whether a `python=` interpreter can host a worker directly
asked whether *trio* was importable, so a trio-less install answered no and
got uv-provisioned instead of used.  It asks `worker` now -- can you serve
a worker at all -- with `engines` alongside it for anyone who wants to
know, and falls back to the `trio` key for a remote old enough to predate
the neutral one.  Nothing has shipped, so this is the shape it ships with.

The service limiter moves from a trio `RunVar` onto the gateway.  A per-run
variable was the wrong owner anyway: the budget it slices belongs to one
loop, and the gateway is what every service call site already has --
including a pure-async worker, which has a gateway but no engine object.
`_ThreadChannel` takes a portal for the same reason: asyncio's thread hop
gives a thread no way back to its loop, where trio's does.

Verified with a real trio-less venv: a trio coordinator drives a worker
that cannot import trio, over popen, and round-trips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite runs green on each engine separately, which is not the same as
the two talking to each other -- and the wire is exactly where a difference
would hide, since nothing about a frame says which library wrote it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three questions a caller asks -- did the other side fail, is the connection
gone, did I use this wrong -- and today the last two are the same type, so
anyone retrying on connection loss also retries on their own bugs.

Records the shape to land on, what moves and why, and the one outright bug:
`execnet.TimeoutError` shadows the builtin *without subclassing it*, so
`except TimeoutError:` catches nothing and only `except OSError` works.
Since 3.11 `asyncio.TimeoutError` is the builtin, so async users' instincts
are actively wrong there.

Everything proposed is additive -- each new type subclasses what is raised
today -- so the nine `pytest.raises(OSError)` sites stay green and the suite
is a regression check rather than something to rewrite.

Also records the rule the asyncio port made necessary: nothing from `_async`
may reach user code.  One already did, and cost a `HostNotFound`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The constraints are two, and both were measured rather than assumed: the
six error names execnet 2.1.1 exported, and the three places pytest-xdist
3.8.0 reaches into -- `execnet.DumpError`, `Channel.RemoteError` as a class
attribute, and `except OSError:` around its shutdown send.

That last one makes `ChannelClosed`/`GatewayGone` being `OSError` subclasses
a compatibility requirement rather than a taste, and it corrects the plan on
one point: narrowing `EOFError` would have broken 2.x-era code that catches
it to mean "the connection died", which is documented behaviour.
`GatewayGone` inherits from both `OSError` and `EOFError` instead, so the
distinction becomes available without anything ceasing to be caught.

Records the one intentional break (API misuse on a channel stops being an
`OSError`) and a follow-up for the three accommodations that exist only for
released xdist and should go once it stops needing them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three questions -- did the other side fail, is the connection gone, did I
call this wrong -- and the last two used to be the same type, so anything
retrying on connection loss was also retrying on its own bugs.

`execnet.TimeoutError` derives from the builtin now.  It shadowed it without
subclassing it, so `except TimeoutError:` caught nothing and only
`except OSError` worked; since 3.11 `asyncio.TimeoutError` *is* the builtin,
so async callers' instincts were actively wrong.  The builtin is an
`OSError`, so this only widens.

`ChannelClosed` and `GatewayGone` name the two ways a connection can be
finished, both `OSError` subclasses -- which is a compatibility requirement,
not a taste: xdist's `workermanage.py` swallows exactly that around its
shutdown send.  `GatewayGone` is also an `EOFError`, because that is how a
broken connection has always surfaced.

`ExecnetStateError` takes the API-misuse cases off `OSError` entirely.  That
is the one deliberate break, and the suite found it immediately -- three
assertions were pinning the old conflation.

`testing/test_errors.py` pins the inheritance rather than the messages,
since inheritance is the contract: what xdist needs, and that nothing from
`_async` reaches user code (one already had, and cost a `HostNotFound`).
Mutation-checked -- reverting either the TimeoutError base or the misuse
split fails five of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reference listed seven types alphabetically, which tells a reader
nothing about which one to catch.  They are grouped now by the three
questions they answer -- did the other side fail, is the connection gone,
did the call itself go wrong -- with the note that every reason a connection
can be gone is an `OSError`, so one `except` covers them all.

The namespace parity test grows the three new names, so a surface that
forgets one fails rather than drifting.

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