Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3744b19
feat: run popen Message IO on Trio host threads
RonnyPfannschmidt Jul 22, 2026
beb36b9
feat: schedule worker remote_exec from the Trio nursery
RonnyPfannschmidt Jul 22, 2026
37a184f
refactor: drop eventlet and gevent execmodels
RonnyPfannschmidt Jul 22, 2026
60e6ce9
feat: bootstrap same-python popen worker via `python -m`
RonnyPfannschmidt Jul 22, 2026
dfe7391
add claude settings
RonnyPfannschmidt Jul 22, 2026
603dfc6
feat: provision foreign-python popen workers via uv
RonnyPfannschmidt Jul 22, 2026
7656368
test: add local ssh-connect harness + typing cleanups
RonnyPfannschmidt Jul 23, 2026
f153271
feat: run ssh gateways on the Trio path
RonnyPfannschmidt Jul 23, 2026
5197b1e
feat: ship the execnet wheel to ssh remotes for dev coordinators
RonnyPfannschmidt Jul 23, 2026
21096da
feat: add execnet-socketserver console command
RonnyPfannschmidt Jul 23, 2026
420693a
feat: run direct socket gateways on Trio with subprocess workers
RonnyPfannschmidt Jul 23, 2026
b83ff3b
feat: migrate installvia to a GATEWAY_START_SOCKET protocol message
RonnyPfannschmidt Jul 23, 2026
62b746b
refactor: remove the legacy socket coordinator code
RonnyPfannschmidt Jul 23, 2026
19d620d
feat: run popen via-gateways on Trio via GATEWAY_START_POPEN
RonnyPfannschmidt Jul 23, 2026
92969c5
feat: spawn arbitrary via sub-gateways with GATEWAY_START_SUB
RonnyPfannschmidt Jul 24, 2026
48d328c
feat: run vagrant_ssh gateways on the Trio path
RonnyPfannschmidt Jul 24, 2026
3d1d31e
refactor!: remove the legacy bootstrap stack and EXECNET_TRIO_HOST hatch
RonnyPfannschmidt Jul 24, 2026
0a24039
refactor: unify transports on StapledStream with sans-IO frame decoding
RonnyPfannschmidt Jul 24, 2026
f271da5
refactor: extract the LoopPortal cross-thread primitive
RonnyPfannschmidt Jul 24, 2026
0eb6cc5
feat: add the trio-native AsyncGateway core with low-level RawChannels
RonnyPfannschmidt Jul 24, 2026
ed5f99f
feat: layer the serialized AsyncChannel API on RawChannel
RonnyPfannschmidt Jul 24, 2026
80ad5df
feat: run async popen gateways with remote_exec inside the user's tri…
RonnyPfannschmidt Jul 24, 2026
b3b1ab8
feat: add AsyncGroup owning gateways as nursery child scopes
RonnyPfannschmidt Jul 24, 2026
8286b77
feat: make the via tunnel frame-native over raw channel routing
RonnyPfannschmidt Jul 24, 2026
57f899f
docs: record B.4 completion in the phase handoff
RonnyPfannschmidt Jul 24, 2026
f932084
feat: rebuild the sync API as a facade over the async gateway engine
RonnyPfannschmidt Jul 24, 2026
80e237a
docs: record B.5 completion in the phase handoff
RonnyPfannschmidt Jul 24, 2026
4f84335
feat: split the public surface into execnet.sync / execnet.trio / exe…
RonnyPfannschmidt Jul 25, 2026
33decae
docs: record phase B completion in the handoff
RonnyPfannschmidt Jul 25, 2026
51b9053
fix: attach the bridge session before it can serve messages
RonnyPfannschmidt Jul 25, 2026
a69b844
chore: add pytest-xdist to the dev group, ignore claude local settings
RonnyPfannschmidt Jul 25, 2026
72b0eef
docs: hand off phase C/D — worker axes gap analysis, supersede the B doc
RonnyPfannschmidt Jul 25, 2026
2742eb0
fix: drain asyncssh redirect cleanup in the local ssh harness
RonnyPfannschmidt Jul 25, 2026
0d123bd
docs: record PR #422 and the xfail audit in the phase C handoff
RonnyPfannschmidt Jul 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(uv run pytest:*)"
]
}
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ include/
.vagrant.d/
.config/
.local/
.claude/settings.local.json
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@ repos:
- id: mypy
additional_dependencies:
- pytest
- trio
- types-pywin32
- types-gevent
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
------------------

* `#380 <https://github.com/pytest-dev/execnet/pull/380>`__: Add support for Python 3.13 and 3.14, and drop EOL 3.8 and 3.9.
* Trio host-thread Message IO for local ``popen`` + import bootstrap (coordinator and
worker). Adds a hard ``trio`` dependency. Disable with ``EXECNET_TRIO_HOST=0``.
Other gateway types and greenlet execmodels keep the legacy thread path.


2.1.2 (2025-11-11)
------------------
Expand Down
27 changes: 12 additions & 15 deletions doc/basics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,10 @@ Examples for valid gateway specifications
same interpreter as the one it is initiated from and additionally
remotely sets an environment variable ``NAME`` to ``value``.

* ``popen//execmodel=eventlet`` specifies a subprocess that uses the
same interpreter as the one it is initiated from but will run the
other side using eventlet for handling IO and dispatching threads.

* ``socket=192.168.1.4:8888`` specifies a Python Socket server
process that listens on ``192.168.1.4:8888``
process that listens on ``192.168.1.4:8888``. Such a server can be
started with the ``execnet-socketserver`` console command, e.g. run
anywhere with ``uvx --from execnet execnet-socketserver :8888``.

.. versionadded:: 1.5

Expand Down Expand Up @@ -138,30 +136,29 @@ processes then you often want to call ``group.terminate()``
yourself and specify a larger or not timeout.


threading models: gevent, eventlet, thread, main_thread_only
threading models: thread, main_thread_only
====================================================================

.. versionadded:: 1.2 (status: experimental!)

execnet supports "main_thread_only", "thread", "eventlet" and "gevent"
as thread models on each of the two sides. You need to decide which
model to use before you create any gateways::
execnet supports "thread" and "main_thread_only" as thread models on
each of the two sides. You need to decide which model to use before
you create any gateways::

# content of threadmodel.py
import execnet
# locally use "eventlet", remotely use "thread" model
execnet.set_execmodel("eventlet", "thread")
# locally use "thread", remotely use "main_thread_only" model
execnet.set_execmodel("thread", "main_thread_only")
gw = execnet.makegateway()
print (gw)
print (gw.remote_status())
print (gw.remote_exec("channel.send(1)").receive())

You need to have eventlet installed in your environment and then
you can execute this little test file::
You can execute this little test file::

$ python threadmodel.py
<Gateway id='gw0' receive-live, eventlet model, 0 active channels>
<RInfo 'numchannels=0, numexecuting=0, execmodel=thread'>
<Gateway id='gw0' receive-live, thread model, 0 active channels>
<RInfo 'numchannels=0, numexecuting=0, execmodel=main_thread_only'>
1

How to execute in the main thread
Expand Down
74 changes: 58 additions & 16 deletions doc/implnotes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,68 @@ capable of receiving and executing code,
and routing data through channels.

Gateways operate on InputOutput objects offering
a write and a read(n) method.
a write and a read(n) method.

Once bootstrapped a higher level protocol
based on Messages is used. Messages are serialized
to and from InputOutput objects. The details of this protocol
are locally defined in this module. There is no need
for standardizing or versioning the protocol.

After bootstrapping the BaseGateway opens a receiver thread which
accepts encoded messages and triggers actions to interpret them.
Sending of channel data items happens directly through
write operations to InputOutput objects so there is no
separate thread.

Code execution messages are put into an execqueue from
which they will be taken for execution. gateway.serve()
will take and execute such items, one by one. This means
that by incoming default execution is single-threaded.

The receiver thread terminates if the remote side sends
a gateway termination message or if the IO-connection drops.
It puts an end symbol into the execqueue so
that serve() can cleanly finish as well.
Trio host-thread IO
-------------------

``popen`` and ``ssh`` gateways run their Message protocol IO inside a
dedicated OS thread hosting a Trio event loop
(``execnet._trio_host.TrioHost``). No source is sent over the wire; the
worker is launched as ``python -m execnet._trio_worker`` and imports the
installed ``execnet`` + ``trio`` (a rough major/minor version check guards
against an incompatible install). How the worker environment is obtained
depends on the target:

* Same-interpreter ``popen`` -> ``sys.executable -m execnet._trio_worker``.
* A ``python=`` interpreter that already has execnet -> that interpreter
directly (so ``sys.executable`` is preserved).
* A bare ``python=`` interpreter or an ``ssh`` remote -> provisioned via
``uv`` (``execnet._provision``): ``uv run --with <req>`` where ``<req>``
is ``execnet==<ver>`` for a released coordinator or a locally-built,
version-cached wheel for a dev coordinator. For an ``ssh`` remote on a
dev coordinator the wheel is not on the remote filesystem, so the remote
command is a POSIX-sh prelude that reads the wheel bytes from stdin
(``head -c N`` into a temp dir) and ``exec``s ``uv`` against it; the
coordinator streams those bytes before the Message protocol.

The worker configuration (id, execmodel, coordinator version) is passed as a
single JSON CLI argument (``_provision.worker_cli_arg``), so every launcher
shares one contract instead of scattered positional args.

Coordinator and worker roles:

* Coordinator: ``trio.lowlevel.open_process`` (directly, or wrapped in
``ssh``) plus async framed reader/writer tasks per gateway (one host
thread per ``Group``). It waits for the worker's ``b"1"`` handshake
before starting the Message protocol.
* Worker: ``serve_popen_trio`` adopts stdio pipe fds into Trio streams and
writes the handshake byte; ``remote_exec`` is scheduled from the Trio
nursery (``trio.to_thread`` for ``thread``, main-thread handoff for
``main_thread_only``).

Sync ``Channel`` / ``Gateway`` APIs are unchanged. Sends from non-host
threads wait until the frame is written (so abrupt ``os._exit`` cannot drop
queued data). Sends from the Trio host thread (receiver callbacks) only
enqueue, to avoid deadlocking the writer task.

``socket`` and ``via`` gateways run on the Trio host too. ``socket``
connects a Trio TCP stream to an ``execnet-socketserver`` (itself a Trio
listener spawning ``python -m execnet._trio_worker --socket-fd`` subprocess
workers). Infrastructure that used to be driven by ``remote_exec``-ing
source is now expressed as native protocol messages handled on the target's
Trio host: ``GATEWAY_START_SOCKET`` (installvia -> bind a one-shot listener,
reply with its address) and ``GATEWAY_START_SUB`` (``via`` -> spawn a
sub-worker — popen, foreign python, ssh, or vagrant — and relay its protocol
over the request channel).

The Trio host is the only IO path; the legacy thread receiver and the
source-shipping bootstrap have been removed. The receiver task terminates
when the remote side sends a gateway termination message or the
IO-connection drops.
182 changes: 182 additions & 0 deletions handoff-phase-b-async-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Handoff: Phase B — invert execnet onto an async-native Trio core

**SUPERSEDED by `handoff-phase-c-worker-axes.md`** — kept as the Phase B
record. **Phase B is complete (B.1–B.6)** on branch
`feat/trio-host-thread-io`; work continues at **Phase C**.

Run checks with `uv run pytest testing/` and `uv run pre-commit run -a`
(never grep-filter pre-commit output). ssh paths have a real local harness
in `testing/test_ssh_local.py` (asyncssh server; system ssh client needed).
Known flake: `test_socket_installvia` EOFs rarely under load.

## Where the repo stands (2026-07-24, after B.5)

Trio is the only IO path; no source is shipped over the wire (workers run
`python -m execnet._trio_worker <config-json>`, foreign/remote interpreters
are uv-provisioned via `_provision.py`, dev coordinators ship a wheel). All
transports work: popen, `python=`, ssh, vagrant_ssh, socket, and `via`
sub-gateways (`GATEWAY_START_SUB` spawn-request + byte relay on the master).

**The inversion landed (B.5)**: there is one protocol engine —
`AsyncGateway` — and the sync API is a facade over it. `ProtocolSession`
is gone. Coordinator and worker sessions are `SyncBridgeGateway`
(an `AsyncGateway` subclass) whose `_dispatch` runs the classic sync
`Message.received` handlers under `_receivelock`; the sync
`Channel`/`ChannelFactory` state machine in `gateway_base` is unchanged
and remains the semantic layer for blocking users (and the worker's
exec'd code). `multi.Group` owns a `FacadeAsyncGroup` task on its
`TrioHost`; `makegateway` and the bounded process shutdown delegate to
`AsyncGroup`, while `terminate` still calls each member's
`exit()`/`join()` (pinned by `test_basic_group`).

File map (src/execnet/):

| file | role |
|---|---|
| `gateway_base.py` | `Message` wire protocol + **`FrameDecoder`** (sans-IO), serializer (incl. duck-typed `save_AsyncChannel` → CHANNEL opcode), sync `Channel`/`ChannelFactory` (raw-receiver registry: `register_raw_receiver`/`allocate_id`, verbatim CHANNEL_DATA routing), `BaseGateway`/`WorkerGateway` (`_send` via bridge session, `_send_nonblocking` for GC), `ExecModel`, `WorkerPool`, `HostNotFound` |
| `_trio_gateway.py` | **the async-native core**: `ByteStream` Protocol, `RawChannel`, `AsyncChannel`, `AsyncGateway` (single serve task; outbound queue items are `(frame, on_written)` so sync senders can wait for the OS write; writer fails pending frames on shutdown; `_finalize` hook for subclass shutdown), `AsyncGroup` (async CM owning the nursery; **all transports**: popen/`python=`/ssh/vagrant_ssh/socket(+installvia)/`via=`; overridable `_make_gateway`/`_open_via_stream`/`_resolve_socket_address`; per-process reaper tasks; terminate = tunneled first, GATEWAY_TERMINATE + timeout + kill, ~2×timeout bound), `RawChannelStream`, `open_popen_gateway`, transport helpers (`connect_command_worker` incl. ssh-255→HostNotFound, `connect_socket_worker`, `start_socketserver_via` (async), `ssh_transport_args`, `popen_worker_argv`) |
| `portal.py` | **`LoopPortal`** (trio-token holder) and **`SyncReceiver`** (loop→plain-thread queue, KI-interruptible `get()`) |
| `_trio_host.py` | sync-facade host: `TrioHost` (loop thread; `start_session` returns a bridge), **`SyncBridgeGateway`** (sync dispatch + portal-FIFO `enqueue_message`/`post_message`/`request_close_write`, threading-`Event` `wait_done`), **`FacadeAsyncGroup`** (bridge-building AsyncGroup; via/installvia through the sync master), `makegateway_trio`, `SyncIOHandle` (remoteaddress/wait/kill/close_write), `RawTunnelStream` (via tunnel; `aclose` feeds own reader EOF), `GATEWAY_START_*` handlers (`_start_sub_and_relay` frame-native) |
| `_trio_worker.py` | worker entry, `TrioWorkerExec` (FIFO `_pump` admission), `_prepare_protocol_fds`; serves on a `SyncBridgeGateway` |
| `gateway.py` | sync coordinator `Gateway` (remote_exec, exit, rinfo) |
| `_exec_source.py` | remote_exec source normalization shared by sync + async coordinators |
| `multi.py` | sync `Group` facade (`_ensure_async_group`, terminate via `AsyncGroup`), `MultiChannel`, `safe_terminate` (WorkerPool-based, kept for tests) |
| `_provision.py` | uv provisioning, wheel build/ship/materialize, argv builders |

Async-core tests live in `testing/test_trio_gateway.py` (memory_stream_pair
protocol tests + popen/via integration inside `trio.run`).

Semantics that MUST survive (xdist depends on them):

- Sends from non-loop threads block until the frame hit the OS write
(120s → OSError) so abrupt `os._exit` cannot drop "sent" data; sends from
the loop thread only enqueue. After close: `OSError("cannot send (already
closed?)")`; `trio.RunFinishedError` maps to the same.
- exec admission order = message arrival order (see `TrioWorkerExec._pump`;
`test_main_thread_only_concurrent_remote_exec_deadlock` guards this —
trio shuffles its run batch, so never rely on task-spawn order).
- Channel callbacks run on the receiver (loop) thread — keep for now.

## Phase B goal

Three public namespaces, all pure Trio (**no anyio/asyncio** — an
`execnet.anyio` backend is deferred Phase E; keep idioms portable: neutral
stream protocol, sans-IO protocol logic, no gratuitous trio-only constructs):

```
execnet.sync – today's blocking API, rebuilt as a facade (top-level
execnet.Group etc. stay as compatibility aliases)
execnet.trio – trio-native AsyncGroup/AsyncGateway/AsyncChannel, awaited
directly inside the user's own trio.run
execnet.portal – the communicating API (exists as portal.py; public
exposure happens in B.6)
```

## Remaining work

### B.4 Async-native core — DONE (commits `0eb6cc5`..`8286b77`)

Everything lives in `_trio_gateway.py` (see file map). Landed:
RawChannel + AsyncChannel two-level model, AsyncGateway single-task
dispatch (no `_receivelock` on the async path), AsyncGroup with bounded
nursery-scoped termination, async popen/`python=`/`via=` gateways with
`remote_exec` inside the user's own `trio.run`, and the frame-native via
tunnel (raw-receiver registry in the sync `ChannelFactory`;
`ChannelByteStream` is gone).

Leftovers deliberately deferred:

- **ChannelFile / makefile over RawChannel** — do together with the B.5
facade (the current `ChannelFileRead` is str-based; decide there whether
a text wrapper stays for backward compat).
- rsync data plane / wheel shipping over raw channels — later.
- async ssh/socket gateways — the async `makegateway` only does
popen-style and `via=`; other transports stay on the sync host path
until B.5/B.6 need them.
- worker-side CHANNEL_EXEC on an AsyncGateway is rejected with a
RemoteError ("unsupported message") — async exec is Phase C
(`exec=task`).

### B.5 Rebuild the sync API as a facade — DONE (commit `f932084`)

Public surface stayed byte-for-byte; the whole sync suite passes
unchanged (508 passed). What landed, and the decisions taken:

- One engine: `SyncBridgeGateway(AsyncGateway)` serves both coordinator
and worker; `ProtocolSession` deleted. Sync dispatch still runs the
`gateway_base` Message handlers, so the sync `Channel`/`ChannelFactory`
semantics (queues, callbacks, ENDMARKER, weakref drop) are untouched —
and remain what the worker's exec'd code sees.
- Send invariant kept: `enqueue_message` posts every frame through the
portal (one global FIFO); non-loop threads wait on the frame's
`on_written` ack (120s → OSError), the loop thread only enqueues.
- `Channel.__del__` now goes through `_send_nonblocking` →
`SyncBridgeGateway.post_message` (portal post, never waits; falls back
to `_send` for duck-typed test gateways).
- KI decision: blocking data paths (send-ack, receive, waitclose, join)
wait on `threading.Event`/`queue.Queue` — KI-interruptible as before.
Management ops (makegateway, terminate) run inside `portal.run`
(`trio.from_thread.run`) where KI is deferred; accepted for now.
- `Group.terminate` still calls member `exit()`/`join()` (contract pinned
by `test_basic_group`), with process wait/kill delegated to
`AsyncGroup.terminate` (tunneled-first, ~2×timeout bound).
- Gotcha fixed on the way: a stream-closing `aclose` on the via tunnel
(`RawTunnelStream`) must feed EOF to its own reader, else the bridge's
serve task never finishes and terminate hangs.
- Post-B fix (`51b9053`): the session-attach startup race — the bridge
must attach itself to the sync gateway in `__init__`, before serving
starts, or a first-message reply goes through the IO stub and kills
the fresh gateway (predated B.5; surfaced by `pytest -n 12`). The
suite now runs xdist-clean.
- `ChannelFile`/`makefile`: kept the existing str-based `gateway_base`
classes as-is for backward compat (they only use channel send/receive).
- NOT yet retired: `ExecModel` internals and `WorkerPool` (still the
worker STATUS duck-type shape and used by `multi.safe_terminate`,
`testing/test_threadpool.py`, conftest's `pool` fixture). Do this at
the end of the phase (B.6 or later) if the tests pinning them move.

### B.6 Namespace split — DONE (commit `4f84335`)

`execnet.sync` (blocking facade re-exports; top-level `execnet.*` aliases
into it), `execnet.trio` (AsyncGroup/AsyncGateway/AsyncChannel, raw
channels, stream helpers, shared serialization + errors), and
`execnet.portal` (LoopPortal/SyncReceiver). `trio`/`portal` load lazily
via module `__getattr__`, so `import execnet` still does not import the
trio event loop (pinned by `testing/test_namespaces.py`). Trio-native
protocol/integration tests already live in `testing/test_trio_gateway.py`
(memory_stream_pair + real popen gateways inside `trio.run`).

### End-of-phase leftovers (deferred into C/D)

- Retire `ExecModel` internals and `WorkerPool`: still pinned by
`multi.safe_terminate`, `testing/test_threadpool.py`, and conftest's
`pool` fixture, and `get_execmodel`/backend names feed Phase C's
`execmodel=` compat mapping — retire once C lands the new worker
config axes.
- ChannelFile / makefile over RawChannel (async makefile) — untouched;
the sync str-based wrappers stay for backward compat.
- rsync data plane / wheel shipping over raw channels — later.
- Async remote_exec on the worker (`exec=task`) — Phase C.

## Invariants (do not regress)

- No source shipping, ever. Workers import installed execnet+trio; rough
major/minor version check (`_trio_worker._check_version`) warns.
- Wheel-on-demand for `GATEWAY_START_SUB` is a recorded TODO in
`_provision.spawn_request` (currently ships eagerly whenever the sub-spec
has `python=`/`ssh=`).
- Worker teardown: `_trio_worker._run_worker` ends with `os._exit(0)`
because trio's `to_thread` cache uses non-daemon threads.
- `Group.terminate(timeout)` must never hang (bounded even when kill is
stuck).

## After Phase B (context, don't build now)

- **C**: worker config axes `loop=thread|main` × `exec=thread|main|task`
(compat: `execmodel=thread` → loop=main+exec=thread; `main_thread_only` →
loop=thread+exec=main); `exec=task` later enables async remote_exec.
- **D**: docs + trio-surface tests, pytest-xdist verification.
- **E** (deferred): `execnet.anyio` — coordinator core on anyio,
`backend="asyncio"` knob for the facade. `FrameDecoder` and the B.4
channel layers are IO-free by construction, so they port as-is; anyio's
`StapledByteStream` satisfies the `ByteStream` protocol structurally.
Loading
Loading