Skip to content

Rework execnet onto an async-native core (sync facade, trio or asyncio engine, uv-provisioned workers) - #422

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

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

Conversation

@RonnyPfannschmidt

@RonnyPfannschmidt RonnyPfannschmidt commented Jul 26, 2026 •

Copy link
Copy Markdown
Member

Written by Claude Opus 5.5 via Claude Code for the execnet maintainers; I prompted it, it did the work, I read it.

This is execnet 3.0. Released pytest-xdist keeps working unmodified, and CI runs xdist's own test suite against this branch to check it. CHANGELOG.rst has the detail.

What this does

  • One async protocol core, running on trio or asyncio (Python 3.11+). trio becomes an extra there.
  • One engine thread per process, shared by every surface:
    • execnet / sync: blocking;
    • gevent: blocking, parks greenlets;
    • trio and aio: awaitable facades;
    • raw_trio: gateways as tasks in your own nursery.
  • No source shipping. Workers run an installed execnet and are launched through the execnet worker|server|info CLI. Foreign interpreters and ssh/vagrant remotes are provisioned with uv.
  • The protocol leaves stdio:
    • transport=socket is the default: a socketpair on POSIX, socket.share() on Windows, an ssh -R dial-back for ssh.
    • A worker's stdio belongs to the code it runs.
    • The config no longer travels in argv.
    • Windows is supported and tested.
  • Worker profiles thread (default), trio and gevent. execmodel= becomes profile= and stays as an alias; main_thread_only is deprecated.
  • Deployment / transfer: a uv environment plus the project's wheel put on a host before any worker runs, a local→remote path mapping, and diff-only tree transfer. RSync becomes a deprecated adapter over transfer.
  • Errors answer three questions, the same on every surface:
    • the other side failed: RemoteError;
    • the connection is gone: ChannelClosed, GatewayGone, TimeoutError, HostNotFound, all OSError subclasses;
    • the call was wrong: ExecnetStateError, not an OSError.
  • The wire format is typed. Nothing changes at runtime.

Breaking:

  • Remotes need execnet installed, or uv to provision it.
  • dump/load/loads and execnet.script.* are removed.
  • The pre-3.0 module names warn and forward.
  • Three misuse cases move from OSError to ExecnetStateError.
  • A blocking call inside a running event loop raises instead of hanging.
  • eventlet support is removed.

Tests: 888 passed, 66 skipped, sequentially and under -n 12. pre-commit run -a and tox -e docs (-W plus doctests) are clean. The ssh paths use a real local harness.

Follow-ups

Before 3.0:

  • Take set_execmodel out of __all__; it stays importable and warning.
  • execnet info: protocols has no reader and omits share on Windows. Either give it a reader or document it as informational. It is a cross-version contract.
  • Decide whether 3.0 reserves space on the wire for flow control. Channels have none today: a fast sender is buffered on the other end without limit, not throttled. Adding the space after release is the expensive version.
  • Bug: execnet.gevent fails in a monkey-patched process. The engine switches to asyncio as intended, but patched threading makes the event-loop guard refuse every blocking call.
  • Windows 3.14/3.15: the socket.share() path intermittently fails with WSAENOTSOCK. The server-side share also has an unproven race.

3.x:

  • A cross-engine protocol test: trio and asyncio relays chained through via=.
  • Kubernetes pods over the protocol. First decide whether this lives in execnet or out of tree.
  • Deployment: workspace cleanup, and a lock against two concurrent deploys to one host.
  • installvia without a socket handoff: spawn the worker as the listener.
  • Async surfaces: remote_status, MultiChannel, RSync.
  • Remove the xdist compatibility shims once xdist has released without them. That also needs an xdist PR for test_remote_inner_argv.

RonnyPfannschmidt added a commit to RonnyPfannschmidt/execnet that referenced this pull request Jul 26, 2026
…doff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the feat/trio-host-thread-io branch from e8f0d49 to e43db63 Compare July 30, 2026 10:33
@RonnyPfannschmidt
RonnyPfannschmidt requested review from Zac-HD, bluetech and hugovk and removed request for bluetech September 25, 2026 17:31
RonnyPfannschmidt and others added 22 commits September 25, 2026 22:23
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 Grok 4.5 via Cursor <ai@cursor.sh>
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 Grok 4.5 via Cursor <ai@cursor.sh>
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.

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 via Claude Code <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 via Claude Code <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 via Claude Code <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 via Claude Code <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 via Claude Code <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 via Claude Code <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 via Claude Code <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 via Claude Code <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 via Claude Code <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 via Claude Code <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 via Claude Code <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 via Claude Code <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 via Claude Code <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 via Claude Code <noreply@anthropic.com>
- 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 via Claude Code <noreply@anthropic.com>
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).

Co-Authored-By: Claude Fable 5 via Claude Code <noreply@anthropic.com>
The start of 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 via Claude Code <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 via Claude Code <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 via Claude Code <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 via Claude Code <noreply@anthropic.com>
RonnyPfannschmidt and others added 25 commits September 25, 2026 22:23
`_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) via Claude Code <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) via Claude Code <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) via Claude Code <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).

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) via Claude Code <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) via Claude Code <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) via Claude Code <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) via Claude Code <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) via Claude Code <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) via Claude Code <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.

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

A trio-less install has to *run*, too, and five modules imported trio 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 a neutral capability key.  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) via Claude Code <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; three assertions in the suite 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`).
Reverting either the TimeoutError base or the misuse split fails five of
them.

The API reference groups the errors by those three questions instead of
alphabetically, and 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) via Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) via Claude Code <noreply@anthropic.com>
Three quarters of the `Any` in the tree was not describing anything
open-ended -- it was discarding a type that already existed, or one the
code documented in prose and never wrote down.

Erasures put back:

* `spec: Any` -> `XSpec`, 28 sites.  The public facades already said
  `str | XSpec`; only the internals threw it away.
* `Transport.connect`/`open` return `BlockingChannel`/`ByteStream`
  instead of `Any` -- both names the docstrings already used.
* `wakener: Any` -> the `Wakener` protocol that was sitting in
  `_boundary` unused by either engine.

What that turned up, none of it annotation churn:

* `configure_worker` handled `spec=None` while its callers' type said it
  could not.
* `loads_internal` had an untyped parameter, and typing it showed
  `AsyncChannel` being passed where `Unserializer` declared
  `Channel | BaseGateway`.  It works by duck-typing, so it is spelled as
  two protocols rather than a union -- the channel layer still never gets
  imported into the serializer, as this module's docstring promises.
* five defensive `getattr(spec, "python", None)` lookups became plain
  attribute access, which unlike getattr actually narrows.

And the wire format now has a name.  `Payload` is a recursive alias for
exactly what `can_send` documents: the builtin scalars, arbitrarily
nested containers of them, and channel references.  It is spelled with
the abstract containers on purpose -- `list` and `dict` are invariant, so
the strict version would reject `channel.send([1, 2])` and be unusable as
an argument type.  The price is that `range` and `memoryview` type-check
and then fail at runtime; `can_send` stays the runtime answer.

It rides on the send side -- `send`, `dumps`, `remote_exec(**kwargs)`,
`Manifest.dump`, `gateway_info` -- so an unsendable value is a type error
where it enters rather than a `DumpError` on the wire.  `can_send` is a
`TypeIs[Payload]`, which finally makes the idiom in its own docstring
type-check:

    channel.send(value if execnet.can_send(value) else repr(value))

The alias is a string and never evaluated, so it costs nothing at runtime
and works on 3.10; only a checker ever resolves it.

BREAKING CHANGE: `remote_exec` keyword arguments and `Channel.send` are
typed `Payload` rather than `object`.  Nothing changes at runtime -- both
always raised `DumpError` for these -- but callers passing `object`-typed
values will need `can_send` or a cast to type-check.

Co-Authored-By: Claude Opus 5 (1M context) via Claude Code <noreply@anthropic.com>
`Any` on the way in was never a description of the wire format -- it was
the absence of one.  Now that `Payload` exists, `receive`, `loads` and
the iteration protocols return it, and a caller that treats a received
value as a specific type has to say so.

`Payload` is generic in the channel it carries, because a channel
reference is a different class on every surface and the two directions
want different things:

* `SendPayload` -- `Payload[ChannelRef]`, a protocol structural on `id`,
  which is the whole of what `save_Channel` writes.  A gateway serializes
  any surface's channel, so the send side asks for the loosest thing.
* `Payload[Channel]`, `Payload[AsyncChannel]` -- what each `receive`
  promises, since a gateway's factory only ever builds channels of its
  own surface.  `execnet.aio` and `execnet.trio` name their own wrapper
  classes here rather than the core's.

Round trips still typecheck: a `Payload[Channel]` is a `SendPayload`.
And `loads_internal` binds the parameter from its caller's return type,
so each surface's `receive` is the one place a channel type is named.

The cost is real: eighteen call sites in the suite now carry a `cast`
naming the shape their own `remote_exec` source sends back.  They read as
documentation of the conversation rather than noise --
`cast("tuple[int, str]", ...)` next to a source that ends
`channel.send((VALUE, sys.executable))` says what the two ends agreed on,
which nothing in the file said before.  Nine casts land in the library
itself, all at wire boundaries where the *protocol* fixes the shape: the
service request frame, the GATEWAY_START_SOCKET reply, GATEWAY_INFO and
STATUS.  The doc example reaches a received channel through an
`isinstance` check rather than a cast.

Three more `Any` go:

* the dict half of the payload was `Mapping[Any, ...]` because `Mapping`
  is invariant in its key and `dict[str, int]` therefore does not satisfy
  `Mapping[Payload, Payload]`.  It is now a `PayloadMapping` protocol,
  covariant in both parameters, which is sound because `save_dict` only
  reads: `.items()` is all it touches.
* `ChannelFactory.new` returns its parameter instead of `Any`.
* `MultiChannel.receive_each` drops its accumulator entirely.

Annotating `make_receive_queue`, whose return type was an implicit `Any`,
turned up a real bug: it built the queue lazily inside the loop over
members, so a group with no members returned `None` instead of a queue.
Built up front now.

The TypeVars that parameterise a `Protocol` base have to exist at
runtime, so only the type-only `ChannelT` carries a PEP 696 default; the
rest spell the permissive form out, and `--strict` enforces that none is
left bare.  typing_extensions stays a checker-time import, so no runtime
dependency is added.

BREAKING CHANGE: `Channel.receive`, `AsyncChannel.receive`, channel
iteration, `MultiChannel.receive_each` and `loads` are typed `Payload`
rather than `Any`.  Nothing changes at runtime.  Code that used a
received value as a concrete type now needs a `cast` or an isinstance
narrowing.

Co-Authored-By: Claude Opus 5 (1M context) via Claude Code <noreply@anthropic.com>
trio 0.34 types receive_some as bytes | bytearray, so its streams stopped
satisfying ByteStream and the mypy hook -- which installs trio unpinned --
failed where the locked 0.33 passed.  FrameDecoder.feed appends into a
bytearray anyway, so it takes either.

Co-Authored-By: Claude Opus 5.5 (1M context) via Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) via Claude Code <noreply@anthropic.com>
The Payload typing put Payload and SendPayload into public signatures,
and nitpicky autodoc cannot resolve names that are deliberately not
exported.  An incremental build skipped the affected pages, so this only
showed on a clean one.

Co-Authored-By: Claude Opus 5.5 (1M context) via Claude Code <noreply@anthropic.com>
The split into ChannelClosed, GatewayGone and ExecnetStateError reached
only the sync channel.  The async core behind execnet.trio, execnet.aio
and execnet.raw_trio still raised a bare OSError for the same events, so
the names those namespaces export were never raised there.

Connection gone: sends on a closed channel are ChannelClosed, sends and
opens on a finished connection GatewayGone, and a write that times out is
execnet.TimeoutError.  The reader and writer store a lost connection as
GatewayGone, which is what receive raises on every surface.  They also
stored the backend's own ClosedResource/BrokenResource before, which
receive then raised as-is; it is kept only as the cause now.

Misuse: a group used before start or started twice, blocking inside a
running event loop or on the engine's own loop thread, and using a closed
engine raise ExecnetStateError instead of plain RuntimeError, which it
subclasses.  Registering a second callback moves from OSError to
ExecnetStateError, the same break as the two channel misuse cases.

Environment and internal-invariant errors stay RuntimeError: a missing
uv, a monkey-patched process, an engine that cannot start.  So does the
deprecated RSync adapter's OSError, which keeps its 2.x behaviour.

Five of the six new tests fail against the previous code; the sixth
(sync send after close) was already ChannelClosed.

Co-Authored-By: Claude Opus 5.5 (1M context) via Claude Code <noreply@anthropic.com>
The changelog still said only the trio engine could host gateways, and
the ProtocolEngine docstring the same; both engines run the whole
protocol.  It had no entries for trio becoming an extra, the execnet
info worker/engines keys, Deployment and transfer, or RSync becoming an
adapter with a changed callback.

Co-Authored-By: Claude Opus 5.5 (1M context) via Claude Code <noreply@anthropic.com>
@RonnyPfannschmidt RonnyPfannschmidt changed the title Rework execnet onto a trio-native core (sync facade, uv-provisioned workers, execnet.trio) Rework execnet onto an async-native core (sync facade, trio or asyncio engine, uv-provisioned workers) Sep 25, 2026
@nicoddemus

nicoddemus commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

@RonnyPfannschmidt did not look at the code yet, but from reading the description, this is a major rewrite with major backward incompatibilities, specially the need to have uv installed on remotes.

IMO it would serve the community better to publish a NEW package. The old execnet can then be sun-setted, just like we did with pylib. pytest-xdist will then migrate to the new package. The new package might be named execnet3 or perhaps a new name might be even more appropriate, given the extent of this rewrite.

@RonnyPfannschmidt

Copy link
Copy Markdown
Member Author

closing this pr- i'll start cot.runsomewhere and get it ready for multiple modes of operation

This branch has not been deployed

No deployments
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.

2 participants