Rework execnet onto a trio-native core (sync facade, uv-provisioned workers, execnet.trio) - #422
Draft
RonnyPfannschmidt wants to merge 139 commits into
Draft
Rework execnet onto a trio-native core (sync facade, uv-provisioned workers, execnet.trio)#422RonnyPfannschmidt wants to merge 139 commits into
RonnyPfannschmidt wants to merge 139 commits into
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-IOFrameDecoder→ 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:execnet/execnet.syncsyncexecnet.trioAsyncGroup/AsyncGateway/AsyncChannel, awaited inside your owntrio.run— no host threadexecnet.aioexecnet.geventtrio/aio/geventload lazily, soimport execnetstill does not import an event loop. The blocking surfaces run their IO on aHost— 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:
RawChannelcarries id-routed verbatim byte payloads, which is what makes thevia=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:
Foreign interpreters (
python=) and ssh/vagrant remotes are provisioned on demand viauv; a dev coordinator builds and ships a wheel, cached remotely.execnet infoanswers JSON, so provisioning learns a remote's version before connecting.The protocol is no longer the worker's stdin/stdout
A new
transport=socket|stdiospec key selects, andsocketis the default for every worker execnet spawns: an inherited socketpair on POSIX, a socket duplicated withsocket.share()on Windows, and anssh -R-forwarded unix socket the worker dials back on for ssh.Two things fall out of that, both of them user-visible fixes:
print()went nowhere at all. Newstdin=/stdout=/stderr=spec keys override.env:values, andpsis 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_UNIXon Windows and Win32-OpenSSH has noStreamLocalforwarding.Worker profiles
execmodel=is now spelledprofile=(the old spelling stays as a permanent alias) and selects where exec'd code runs relative to the worker's protocol loop:thread(default)remote_execclaims the worker's main thread, further ones overflow to pool threadstrio(new)gevent(revived)remote_execon a main-thread hubmain_thread_onlyis deprecated and aliases tothread, which already gives the firstremote_execthe 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=...), theexecmodel=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
releasevariant that blocks and a floatingdefault-branchvariant 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_argvassertssys.argv == ["-c"], which the no-source-shipping launch deliberately changed; it needs an xdist PR).Breaking changes
execnet.dump/load/loadsandexecnet.script.*are gone;can_sendreplaces probing withdumps/DumpError.execnet.gateway_base,gateway,multi,rsync,rsync_remote,xspec) warn and forward;gateway_bootstrap,gateway_ioandgateway_socketare simply gone.EOFError; worker stdio is no longer swallowed.eventletis removed. TheEXECNET_TRIO_HOSTescape hatch and the legacy bootstrap stack are gone.Semantics deliberately kept
OSError), so an abruptos._exitcannot drop already-"sent" data.remote_execadmission order == message arrival order.Group.terminate(timeout)is bounded (~2× timeout) even when a kill sticks.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.mdin the repo root (withHANDOFF.mdfor the current state andhandoff-history.mdfor the record):execnet info— the one item that cannot be changed after release, since it is a cross-version probe contract that currently names our engine;__all__, underscoring two engine methods ontrio.AsyncGateway);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 -aclean.tox -e docsbuilds with-Wand 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