Rework execnet onto a trio-native core (sync facade, uv-provisioned workers, execnet.trio) - #422
Draft
RonnyPfannschmidt wants to merge 103 commits into
Draft
Rework execnet onto a trio-native core (sync facade, uv-provisioned workers, execnet.trio)#422RonnyPfannschmidt wants to merge 103 commits into
RonnyPfannschmidt wants to merge 103 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>
With the hang gone, Windows ran the whole suite for the first time: 522 passed, 4 failed. Three of the four are bugs that were always there and that Linux hid. `execnet server :0` reported a port nothing was listening on. A wildcard bind with an ephemeral port gives each address family its *own* random port -- trio documents this -- and only the first was reported. Which family comes first is platform-dependent, so Linux reported the IPv4 port a `127.0.0.1` client could reach and Windows reported the IPv6 one. All families now share the reported port, and a test pins it rather than relying on the ordering that made this pass here. test_basics wrote its generated check script with `write_text()`, i.e. in the locale encoding, and Python reads source as utf-8 (PEP 3120). A single em-dash in the concatenated `_message` source was enough to make the file unreadable wherever the locale is not utf-8. The fourth was mine: `test_single_thread_on_main` asked `resolve_transport` what the transport was without passing the default, so on Windows it was told "socket" while the worker actually ran on stdio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ndows) 16 of 17 CI jobs are green; this is the seventeenth. PyPy on Windows has `socket.share` as a name but the call does not work, so the capability check passed and the failure surfaced three steps later as `EOFError: bad socket handshake: b''` -- with the master gateway dead and no explanation anywhere. Three separate defects behind that one symptom: * the capability is now settled by *doing* it once, against our own pid, rather than by `hasattr`. A host that cannot hand over a socket refuses the request up front, where a reason can still reach the coordinator. * a failed socket gateway no longer kills the gateway it was requested through. It runs as a task on that worker's host, so asking for one unsupported sub-gateway cost the master too -- which is why the errors cascaded across 51 tests instead of failing one. * `channel.send`/`receive` check for a foreign event loop before checking whether the channel is closed. Both are caller bugs, but which one you were told about depended on whether the peer had closed yet, so the event-loop diagnostic lost a race on the slower interpreter. The socket transport simply cannot work where neither `pass_fds` nor a working `share()` exists, so those gateways skip there with that reason rather than failing. Removing the limitation means not handing the socket over at all -- spawning the worker as the listener and reporting its address back -- which is a bigger change than this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The share probe called `share()` and ignored what came back, so PyPy on
Windows passed it and still failed for real: the coordinator's half is
`share()`, the worker's is `fromshare()`, and either can be the one that
does not work. Sharing to our own pid is what makes testing both cheap
-- the blob we produce is one we are entitled to rebuild, so the round
trip needs no second process. An empty blob now also counts as failure.
test__rinfo raced: `receive()` returns when the *send* arrives and the
`os.chdir('..')` runs after it, so the following `_rinfo(update=True)`
could observe the old cwd. It waits for the exec to finish now. Not
caused by the share work -- it went red on ubuntu PyPy, which had been
green, because it was always a race and PyPy is slow enough to lose it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Narrowed by what *passed*: `popen//transport=socket` is green on Windows PyPy, so `share()` to a child and `fromshare()` in it both work there. Only the server-side path failed -- and the two differ in exactly one thing. The popen path shares a socket object it already has. The server path had only the accepted fd, and wrapped it with `socket.socket(fileno=fd)`, which makes the constructor *detect* family/type/proto by querying the handle. Hand it what the caller already knows instead: trio's socket carries all three, and skipping the detection removes the only step the working path does not perform. Also explains why no probe caught this -- both halves of the mechanism work in-process on PyPy, so nothing short of the real accepted socket could have shown it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit moved the Windows PyPy failure rather than fixing it:
the handshake EOFs are gone (52 -> 0) and the real exception finally
surfaced, one layer deeper -- in the worker rather than in the
coordinator that spawned it.
_trio_worker.py:660 in open: adopt_socket(sock.detach())
_trio_host.py:83 in adopt_socket: socket.socket(fileno=socket_fd)
OSError: [WinError 10014] The system detected an invalid pointer
address in attempting to use a pointer argument in a call
Same defect, second site. `ShareTransport.open` had a perfectly good
socket from `fromshare()`, detached it to a bare fd, and handed that to
`adopt_socket` -- which rebuilds a socket from the handle and so must
re-derive family/type/proto by querying it. That derivation is what PyPy
on Windows cannot do to a handle that arrived through
`WSADuplicateSocket`.
`adopt_socket` now takes either, and the share transport passes the
socket it already has. Reducing a socket to an integer and asking the
next layer to reconstitute it was never buying anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`via=`/`installvia=` gateways spawn and relay for the sub-worker they create, which is what a coordinator does -- the old name said nothing about the role and carried baggage besides. Comments, docstrings, local variables, test gateway ids and the proxy example all follow. Where one sentence covers both parties, only the relaying one is named "coordinator"; the process that requested it is left as "here" or "us", since calling both by the same word is what made the old wording tempting. Also refreshes two doc examples that still expected "thread model" in a gateway repr -- it has said "thread profile" since the `execmodel=` -> `profile=` rename, so those doctests were already stale. The `set_execmodel` section in basics.rst is stale for the same reason and wants a rewrite of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Windows was held on `transport=stdio` until the share path had a green run behind it. It has one now -- all 17 jobs, including `windows-latest, pypy-3.11`, which was the case that took four rounds to pin down. So the platform split goes away: a worker execnet spawns itself gets a socket, by whichever handoff the platform has. That also retires the machinery the split needed. `default=` existed only to say "can, but does not yet", and `default_spawn_transport()` only to compute it; with the answer the same on both platforms they collapse back into `resolve_transport(spec, available=...)`, which is where this started before Windows needed an exception. Two tests lose their `posix_only` marks and now cover Windows: that the protocol really is off fd 0/1 (asserting `--protocol-share` there rather than `--protocol-fd`), and that a worker's stdout reaches the coordinator. The visible consequence on Windows is that one: remote `print()` now goes to the coordinator's console instead of being folded onto stderr, matching POSIX. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fallout from defaulting Windows to the socket transport: two
killed-worker tests went red on all six Windows jobs with
BrokenResourceError('socket connection broken: [WinError 10054] An
existing connection was forcibly closed by the remote host')
where they expected EOFError. A peer that dies abruptly *resets* a
socket, while a pipe just reaches EOF -- the same event, reported two
ways, and until now the transport decided which. Callers test for
EOFError and an endmarker callback has to fire either way, so the reader
maps a broken read onto EOFError and keeps the original as __cause__.
ClosedResourceError stays as it was: that is us closing, not the peer
going away.
This also fixes `socket=` gateways on POSIX, where a reset is equally
possible over TCP and nothing was making it look like EOF either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers what a fresh session needs: the CLI as launch contract, the transport matrix and why ssh on Windows cannot leave stdio, the share handoff and the socket-not-fd rule that took four CI rounds to find, the latent bugs that only surfaced once Windows actually ran, and the failure modes worth preserving. Leads with the CI outage, because the most useful thing to know is that every job reported green-looking failures while executing zero tests for four days -- and therefore that any platform CI has not really exercised should be assumed broken. Open work is listed with the reasoning attached, including the two ideas that were evaluated and *not* taken (a trampoline process) or not yet taken (spawning the installvia worker as the listener), so neither gets re-litigated from scratch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
doc/basics.rst still documented set_execmodel() and a threading-models section that no longer exists. It now covers the namespaces, the worker profiles that replaced execmodels, the full spec-key list including transport= and the stdio dispositions, the shared host thread, and the execnet command line; doc/implnotes.rst is rewritten around the launch contract, transports and provisioning rather than the source-shipping bootstrap it still described. New doc/api.rst is the namespace reference, which also gives the four :mod: targets something to resolve to. The examples said they were automatically tested. They were not: a pytest_plugins line in doc/example/conftest.py has made collecting that directory an error since pytest 7, so nobody noticed the trace sample predating trio, the callback notes describing the old receiver thread, or that basics.rst never imported execnet. They run again -- with the two examples that need a reachable ssh account marked +SKIP -- and tox -e docs runs both them and the -W sphinx build, from a new CI job. The docs had never been built in CI at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Takes inventory of what the four namespaces actually publish now that the docs freeze it, records the delta since 2.1, and asks what would be expensive to undo if the host thread ever ran something other than Trio. The one time-sensitive finding: `execnet info` answers `"trio": <version>` and `_provision.target_has_execnet` reads exactly that key to decide whether an interpreter can host a worker directly. Once 2.2 ships, every coordinator asks every future worker a question that names our engine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The front page opened with "Do not use in new projects" and a project status section promising no further improvements, in a release that adds four namespaces, socket transports, a CLI and a worker profile axis. Being pytest-xdist's backend is still the compatibility bar every change is held to -- that is worth saying, and is not the same as a warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five handoff documents had grown by accretion: each phase appended its own file, superseded parts of the previous one in place, and left the reader to work out which decisions still stood. Three now: HANDOFF.md is the state and the invariants, ROADMAP-3.0.md is what is left to do, and handoff-history.md keeps the landed record -- including a table of decisions that were made and then unmade, so they stay unmade. The release framing they were missing: this branch ships as 3.0, not 2.2. It changes the launch contract, the default transport and the ownership of a worker's stdio, and parts of the tree already said "pre-3.0" while the changelog said 2.2.0. Two goals are written down for the first time. Unmodified pytest-xdist must keep working on 3.0, which makes the deprecated shims part of the release rather than something to remove in it; they go later in 3.x, once the consumers that need them have released without them. And the next xdist should be able to stop hand-rolling deployment: uv-bootstrap a remote python with the project under test installed, rsync the tests, and get back a local-to-remote path mapping. Driving test runs across Kubernetes pods over the protocol is the same problem with a shorter-lived remote, so the transport and proxy options are recorded next to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A host is the loop a gateway's protocol IO runs on, so closing one ends every connection it carried. Two paths did not say so. `Host.close()` left the object reusable, so the next `makegateway()` on a group whose host went away started a *second* loop thread -- which none of that group's gateways are attached to -- and then failed on the stale FacadeAsyncGroup with "<AsyncGroup []> is not entered", leaking a thread per attempt. Closing is final now, and `_ensure_started` says which host died and why the group cannot be reused. `setcallback()` after the loop stopped went through `run_on_loop`'s inline fallback, which is not valid for the consumer switch: it drained the mailbox and set `_has_consumer` before reaching `start_soon`'s guard, so the buffered items were lost, `receive()` answered "channel has receiver callback", and `waitclose()` waited out its timeout for a consumer task that could never exist. It now refuses before touching the channel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he loop Three ways a facade could reach a host that cannot serve it, none of which ended in an error the caller could act on. **A posted callback that raises took the whole loop with it.** Trio turns an exception from an entry-queue callback into a TrioInternalError and tears the run down, so `TrioHost.start_soon`'s guard clause -- reached from inside a `portal.post` by `call_pending` and by the aio bridge whenever a call lost its race with shutdown -- killed every gateway in the process and told the user to file a trio bug. Both spawns now report through the OneShot/future they already resolve every other failure through, and "nothing posted through the portal may raise" is written down as an invariant. **Nothing survives os.fork(), but everything waited as if it might.** The host's loop thread is not duplicated into the child and the worker connections belong to the parent -- yet the parent loop's trio token still *accepts* work in the child, so `run_sync_soon` queued callbacks nobody would ever run and `from_thread.run` waited for a reply nobody would send. A child using an inherited group, gateway or channel (the module-level `execnet.makegateway` among them) blocked forever. LoopPortal and BaseGateway now compare pids and raise ForkedResourceError, an OSError subclass so `__del__` and `except OSError` cleanup keep working, distinct so `_send` can let the real reason through instead of rewriting it as "cannot send (already closed?)". Recovery is the child's to make explicitly: ask for the default host and you get a fresh one to build new groups on. A child no longer runs the parent's atexit cleanup either -- those gateways are not its to terminate. **Group.terminate() and Gateway.join() joined the event-loop guard.** Both block on the host with no bound worth waiting out, which is exactly the stall the guard turns into an error naming `execnet.aio`. An empty group has nothing to block on, so cleaning one up from inside a loop stays fine. Also drops what the IO object no longer does: `wait`/`kill` had no caller left in the tree or in xdist (the async group owns the process handle), and with them goes `SyncBridgeGateway.host_call`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There is a seam between the connect helpers and the group: each helper kills its process if the handshake goes wrong, but once it returns, the worker is running with nobody owning it until `_processes[gateway]` is set a few lines later. A failure in between -- a cancellation, there is not much else -- left a worker no terminate() would ever reach. Forcing one (a `_make_gateway` that raises) leaves `running with PID ...` behind on the old code; now the stream is closed and the process killed and reaped, the same shielded, bounded cleanup the helpers already do. Also, two smaller things found while reading the same paths: - The sync `Group.terminate` holds coordinators back from the first exit pass, which reads like duplicated ordering (`AsyncGroup.terminate` already runs tunneled gateways first) but is not: `exit()` ends with close_write, and a tunneled gateway's termination frames still have to travel through that stream. Comment says so now, code unchanged. - The sync bridge's shutdown no longer hops to a thread to call a coordinator's `_terminate_execution`, which is a no-op. Only a worker has an exec pool to shut down. The receiver-callback pool is documented where it can bite: it is shared and bounded, so callbacks that wait on each other can fill it and stall every channel in the process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cross-checking the host boundary against the things our own suite cannot see -- xdist's suite, and gevent as gevent is actually deployed. **A loop that dies at startup left the caller waiting 30s for a message that named nothing.** The loop comes up on a thread nobody is watching, so `trio.run` raising went to the thread's excepthook while `start()` sat on `_ready` until the timeout and then said "TrioHost failed to start". The exception is captured and re-raised at the call site now. A late failure still goes the loud way -- there, the thread traceback is the only report anyone gets. **execnet.gevent does not work in a monkey-patched process, and said the opposite.** The host loop is a trio program in a side thread; trio wants `select.epoll`, real sockets, a real thread and a real `SimpleQueue`, all of which `gevent.monkey` replaces process-wide. Verified in every variant: `patch_all()` loses epoll, `select=False` gets EBADF on trio's wakeup socketpair, `thread=False, socket=False, select=False` still hits gevent's SimpleQueue and LoopExit. Patching was never what made the namespace work -- its waits park the calling greenlet because they wait on a gevent primitive -- but the docs said "do that yourself, as early as usual", which invites exactly the broken setup. The docs now state the constraint, the startup error names gevent when it is the cause, and the roadmap carries the decision: honest limitation, fight the global patch with `monkey.get_original`, or a transport that needs no in-process loop. **The gevent facade took the blocking portal call after all.** Every management op on it is careful to park the greenlet rather than the hub -- except starting the group's async side, which went through `TrioHost.call` and blocked the whole hub. Short enough that the timing test stayed green, which is how it survived; the new test forbids the method outright. One `Group.host_call` now decides how this facade waits on the host, for makegateway, terminate and the async-group start alike. Also: `RemoteError.warn` can no longer raise. It is a best-effort diagnostic for a channel nobody kept, it can run on the loop and as late as interpreter shutdown with stderr already closed -- and an exception there ends the run for every gateway in the process. The xdist contract is green throughout (195 passed, `test_remote_inner_argv` deselected as documented). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gevent limitation recorded last commit left an open question: can the host loop just ignore the monkey-patching? Answered by experiment, both halves, because the answer decides an engine question rather than a docs one. For trio: only behind a private stdlib. The obvious route fails for non-obvious reasons -- gevent's saved originals are not self-contained (the real `socket.socketpair` still builds `socket.socket` from its own patched namespace), a reference captured before patching does not help (`threading._CRLock` is set to None *inside* the real module), rebinding trio's module globals misses everything an executing class body captured, and trio asserts on primitive identity where it matters most for us -- the entry queue, which is what the portal rides on. Re-executing the pure-Python stdlib against unpatched C primitives and importing trio against *that* does work, verified through every path execnet needs, and costs a second `threading` and `socket` in the process: a host thread the application cannot see, and socket identity split in two while our own API takes sockets from user code. For asyncio: nothing. gevent replaces `selectors.DefaultSelector` where it deletes `select.epoll`, so a patched process hands asyncio a working hub-backed selector, and asyncio asserts nothing about identity. Verified on a fully patched process, every path, in both shapes -- on a thread, and as a greenlet on the application's own hub with no host thread at all. Which turns "a non-Trio engine" from an internals port into something with a user-visible payoff, so it is recorded there too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two ends are installed independently now that no source is shipped, and the protocol is unversioned -- so a major/minor skew has no defined behaviour to warn about. `_check_version` raises SystemExit, before apply_stdio touches the worker's stdio: that is the last moment a reason can reach the user, since afterwards the coordinator only ever learns EOF. Patch-level skew stays tolerated, and EXECNET_IGNORE_VERSION_SKEW=1 gets the old warning back. It is read from the config's env: values as well as the process environment, because _apply_worker_setup runs after the check -- so `popen//env:EXECNET_IGNORE_VERSION_SKEW=1` reaches it, which is the only way a spec can set a remote worker's environment. The check had no coverage at all; TestVersionSkew now pins both outcomes, both override paths, and end to end that the worker exits with the reason on stderr while the coordinator's socket sees EOF rather than a handshake. Also adds --protocol-share to the changelog's launch-contract block, which listed four of the five protocol flags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HANDOFF and the roadmap had drifted from what is actually there: - the test count was 552; it is 574. `tox -e docs` doctests all of doc/, not just doc/example/ -- so basics.rst is verified, while test_debug.rst and test_ssh_fileserver.rst have no doctests and nothing checks them. - `execnet info` answers six keys, not five, and "version" is spelled "execnet". Its `protocols` list has no reader anywhere and omits `share`, so a Windows remote understates itself -- recorded under roadmap item 1, which is where that payload gets settled. - the file map was missing _trace.py, _gevent_support.py and __main__.py. - roadmap section 5 "Stale wording" was itself stale: the ExecModel docstring and _shim.REMOVED_IN were both fixed already. Same for the changelog, which is renumbered to 3.0. Elsewhere: the README still advertised zero-install bootstrapping and eventlet, which 3.0 reversed and killed respectively; and the deselect in xdist-known-failures.txt justified itself with a launch command (`python -m execnet._trio_worker`) that is not the launch contract, in the file a maintainer reads to judge whether the deselect still holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… runs Two failures found reviewing the branch, one behind the other. A thread-shaped exec costs a thread of trio's default limiter, and so does a main-thread exec (it parks there waiting for the main thread) -- so 60 concurrent remote_execs on one worker ran 40 and left the rest admitted, waiting for a slot only a finishing exec could free. From the coordinator that is not a queue, it is a remote_exec that hung on a channel nobody will answer, and remote_status compounded it by reporting numexecuting=60. Admission is now bounded at half the budget -- callbacks and the worker's own to_thread work need the other half -- and the request over the line is refused with a RemoteError naming the limit. remote_status grew execcapacity to report it, None under profile=trio where execs are tasks. Deadlocking that worker exposed the second one. executetask closes the channel when the source returns, which is how the coordinator learns it finished; a connection that went away first makes that close raise, and the OSError travelled out of the exec task into the worker's *root* nursery, ending trio.run. Since 3.0 the worker's stderr is the user's, so what they got was an ExceptionGroup of 24 tracebacks in their terminal. The close now tolerates a dead connection -- there is nobody left to tell -- and the exec task contains anything else, which is what every host.start_soon entry point already did except this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`socket=`/`installvia=` accepted `profile=`, `chdir=`, `nice=` and `env:`, validated them, and dropped them: that worker is spawned by the *server*, which built the config itself and never saw the spec. 2.x applied them through a post-bootstrap remote_exec for every gateway type, so this was lost in the port -- quietly, which is the bad part. `profile=trio` on a socket spec got a thread worker and said nothing. The config now travels over the connection, one JSON line ahead of the protocol, read a byte at a time so the frames after it stay for the worker that inherits the socket. The accepting side is a trust boundary, so it takes the keys a spec legitimately carries and none of its own -- the share blob is bound to a pid only the server knows. A peer that sends no config line within ten seconds gets its connection closed instead of a worker, and unlike a failed spawn that does not take the accept loop down: a port scan should cost one closed socket. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
safe_terminate had no caller in src/ since termination moved into AsyncGroup._terminate_one -- only the deprecated-name map and three tests, so the pytest-dev#43/pytest-dev#221 bound was being tested on a function nothing used. Removed, and the bound is now asserted on Group.terminate() against a worker that ignores SIGINT and never returns from its exec. AsyncGroup allocated ids as "gw%d" % len(self._gateways), over a list that terminate() empties -- so gw0 could name two different workers in one session, in its traces and in whatever the caller keyed on it. Also records the two findings that are not fixes: execnet.aio can drop an item when a receive is cancelled after the host already took it (the docstring promised otherwise and now says what it can), and the channel has no flow control at all -- 500 MiB lands in a non-consuming peer's memory in 0.33s with nothing pushing back. The second wants HTTP/2-shaped windows plus the reporting that makes a full window distinguishable from a hang, and a credit field is cheaper to reserve before the protocol ships. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e had CI caught the first one on every Linux runner: the skewed-version test spelled an "impossible" coordinator version, 0.1.2, and a wheel built from a checkout without tags is 0.1.dev1 -- which is exactly what CI installs. The versions matched, the worker started and served, and the test blocked on its output until it timed out. The skew is derived from the real version now, the way the unit tests around it already did. The second was mine too, in the message I had just written: it points at profile=gevent as the way past the exec cap, and profile=gevent was capped right along with thread. Waiting for an exec that runs elsewhere -- the main thread, a greenlet -- parked a pool thread on a threading.Event, so execs that cost no thread each held one anyway, and a gevent worker was rationed to 20 concurrent greenlets. That is a cap on the one thing the profile is for. The wait is a trio.Event woken from the exec's own thread now, and capacity is the strategy's to declare: None where execs are tasks or greenlets, half the thread budget where they are threads. Also drops the last of the review's stale wording: Channel.send has not blocked on a full queue since 2.1's write lock, and says what it does do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ots at the close
Windows CI, intermittently, on 3.14 and 3.15: a popen worker died before
its handshake with WSAENOTSOCK, which reached the coordinator as a reset
connection during makegateway. socket.share() hands the child a *blob*,
and there is no socket on the other end until it calls fromshare() -- so
closing our copy the moment the spawn returns is a race the child loses.
The POSIX comment ("the child holds its own copy now") was true for
pass_fds and quietly wrong for share. We now close after the handshake
there, which is the point where the child has provably adopted.
The server side has the same race and is not fixed: it cannot wait for a
handshake that goes to the coordinator, and holding the socket instead
would cost the coordinator its EOF when a worker dies. Recorded with the
option it needs (a marker byte from the worker) in HANDOFF and the roadmap.
Second, from a flake in the exec-capacity test under -n 12, which turned
out to be the feature and not the test: the admission slot was released
when the exec *task* unwound, a moment after executetask had already sent
the channel close. That close is exactly what tells a coordinator at
capacity it may send the next request, so waitclose() + remote_exec()
could be refused for a slot that was already free. The release moved into
_close_finished, ahead of the close, and is idempotent so the task's
finally still covers whatever never got that far.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…plain itself Holding the coordinator's copy of the socketpair until the handshake was a guess at the Windows 3.14/3.15 failure -- the blob really is not a socket until the child calls fromshare(), so closing early looked like the race. CI says otherwise: WSAENOTSOCK still, and now a 20s hang on top, because a worker that dies while we hold the pair open produces no EOF for anyone to notice. Reverted; the guess cost less than leaving it in would have. What the round is worth keeping is the diagnosis: a worker that never reaches its handshake now reports its exit status instead of whatever its closed socket happened to look like, which is the one fact CI could not tell us this time. The Windows breakage itself is recorded in HANDOFF and the roadmap as open, with the theory that has already been refuted, so the next attempt starts from a bisect rather than from this one again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine of them, mine and older, handed to trio SocketStreams and then dropped. A dropped wrapper still owns its handle and closes it whenever GC gets to it, which is a handle closing at an arbitrary later point in the run -- the kind of thing that only ever shows up on Windows, and the main suspect for the WSAENOTSOCK the share handoff started reporting there today. Hygiene either way: a test that opens sockets closes them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The matrix here stopped at 3.14 and setup-python had no allow-prereleases,
so a 3.15 job could only ever die at setup ("version '3.15' with
architecture 'x64' was not found") -- main has both and the PR runs
inherit them through the merge, which is why this only bites a direct
push to the branch. That is not academic: the Windows failure being
chased is 3.14/3.15-only, and every probe pushed to a test-me-* branch was
silently testing neither of the two prerelease jobs properly.
Mirrors main: 3.15 in the matrix, allow-prereleases on the matrix job
only (docs and xdist pin a released 3.13), and the 3.15 classifier.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A released version should be tested as users get it; the flag is only needed by the one entry that has no release yet. Expression on the matrix value, so 3.10-3.14 and pypy resolve exactly as before -- move it to the next version when 3.15 ships. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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