Skip to content

Commit ddce312

Browse files
docs: record the surface review in the changelog and handoffs
Adds the changelog entries for the four namespaces, the shared Host, the loop guards, profile=/main_thread_only and the aio cancellation fix, and corrects the two earlier entries that still advertised execnet.portal. The phase C handoff gains a "Surface review" section that wins over everything below it, and the boundary-protocol handoff is marked superseded where it promised a Wakener extension point. Also drops the EXECMODEL_PROFILES entry from the gateway_base shim: that name was added on this branch, so it never existed pre-Trio and nothing can be forwarding to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e75cd0a commit ddce312

16 files changed

Lines changed: 203 additions & 55 deletions

CHANGELOG.rst

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,61 @@
11
2.2.0 (UNRELEASED)
22
------------------
33

4+
* One namespace per concurrency library you drive execnet from:
5+
``execnet.sync`` (plain threads; the top-level ``execnet.*`` aliases),
6+
``execnet.trio``, ``execnet.aio`` and the new ``execnet.gevent``, whose blocking
7+
waits park the calling greenlet instead of its OS thread (needs ``execnet[gevent]``).
8+
9+
``execnet.trio`` is the only surface that runs gateways *directly*, as tasks in your
10+
own nursery. The others drive a Trio host thread, so their blocking calls now raise
11+
when made from inside a running asyncio or trio loop -- naming the namespace to use
12+
instead -- rather than stalling that loop. Channels inside a worker are exempt:
13+
exec'd code may run its own event loop and talk to its channel from within it.
14+
* The ``execnet.portal`` namespace is gone. It exposed the ``Wakener`` protocol and the
15+
``Mailbox``/``OneShot``/``LoopPortal`` primitives but not the registry needed to plug
16+
a ``Wakener`` in, and execnet does not offer third-party event-loop integration: a new
17+
concurrency library gets a namespace of its own, as gevent just did. The primitives
18+
are internal again.
19+
* Gateway groups share one Trio host thread per process instead of starting one each.
20+
Pass ``execnet.Host()`` as ``Group(host=...)`` (or ``AsyncGroup(host=...)``) for an
21+
isolated loop with deterministic teardown -- ``Host`` is a context manager and joins
22+
its thread on exit, where the shared one stops at interpreter exit.
23+
* The ``execmodel=`` spec key is now ``profile=``; ``execmodel=`` remains an accepted
24+
alias. It always selected the *worker* profile -- where exec'd code runs relative to
25+
the worker's protocol loop -- while the local execution model it was named after no
26+
longer exists. Accordingly ``Group(execmodel=...)``, ``Group.set_execmodel()``,
27+
``Group.execmodel`` and ``Group.remote_execmodel`` are deprecated in favour of
28+
``Group(profile=...)``, ``Group.set_profile()`` and ``Group.profile``; only the
29+
remote default they set has any effect. ``remote_status()`` reports both
30+
``profile`` and ``execmodel``.
31+
* The ``main_thread_only`` profile is deprecated and now behaves like ``thread``, which
32+
already hands the first ``remote_exec`` the worker's real main thread -- the
33+
GUI/signal-safety property it was added for in 2.1.0. Its other behaviour is gone:
34+
a second concurrent ``remote_exec`` used to close the channel with
35+
``concurrent remote_exec would cause deadlock``, and now runs on a pool thread. That
36+
guard was a one-second timeout that reported a merely slow predecessor as a deadlock.
37+
* The ``wait=`` spec key added earlier in this release cycle is gone. Which primitive a
38+
blocking wait parks on describes the *caller*, which is what choosing a namespace
39+
already says; a worker's own backend is derived from its profile.
40+
* ``execnet.aio`` now propagates cancellation. Cancelling an awaited ``receive`` (with
41+
``asyncio.timeout``, say) cancels the host-side operation, where it previously
42+
abandoned only the asyncio side and let the operation consume an item that was then
43+
discarded. ``send``, ``send_eof``, ``aclose`` and ``terminate`` are shielded instead,
44+
so they cannot tear halfway.
45+
46+
Its classes gained the ``Async`` prefix -- ``AsyncGroup``, ``AsyncGateway``,
47+
``AsyncChannel`` -- matching ``execnet.trio``, and ``AsyncGroup`` can be driven with
48+
``start()``/``aclose()`` from application lifespan hooks instead of ``async with``.
49+
``open_popen_gateway`` is renamed ``open_gateway`` on both async namespaces, since it
50+
always accepted any spec.
51+
* ``execnet.dumps``, the temporary pytest-xdist compatibility shim, now warns once per
52+
process rather than on every access. xdist reaches it from ``serialize_warning_message``,
53+
i.e. from inside pytest's warning-recording hook, so a single ``DeprecationWarning``
54+
raised in a worker made recording that warning record another, unbounded, wedging the
55+
run.
56+
* ``Gateway.remote_init_threads()`` raises a ``DeprecationWarning`` instead of printing
57+
to stdout. It has been a no-operation since execnet 1.2.
58+
459
* `#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.
560
* Trio host-thread Message IO for local ``popen`` + import bootstrap (coordinator and
661
worker). Adds a hard ``trio`` dependency. Disable with ``EXECNET_TRIO_HOST=0``.
@@ -31,7 +86,8 @@
3186
load. Opcode bytes are unchanged for every type that survives.
3287
* The supported API is now exactly five namespaces: ``execnet`` (aliases of
3388
``execnet.sync``), ``execnet.sync``, ``execnet.trio``, ``execnet.aio`` and
34-
``execnet.portal``. The pre-Trio modules ``execnet.gateway_base``, ``execnet.gateway``,
89+
``execnet.gevent`` -- one per concurrency library you drive execnet from. The
90+
pre-Trio modules ``execnet.gateway_base``, ``execnet.gateway``,
3591
``execnet.multi``, ``execnet.rsync``, ``execnet.rsync_remote`` and ``execnet.xspec``
3692
were only ever reachable because ``import execnet`` pulled them in transitively; they
3793
are now deprecated forwarding shims that warn on attribute access and will be removed
@@ -55,8 +111,7 @@
55111
``can_send``.
56112
* ``execnet.trio`` no longer exports ``ByteStream``, ``RawChannel``,
57113
``RawChannelStream`` or ``serve_gateway``; the raw-channel layer is internal routing
58-
detail. No names were added to ``execnet.sync``, ``execnet.aio`` or
59-
``execnet.portal``.
114+
detail. No names were added to ``execnet.sync`` or ``execnet.aio``.
60115

61116

62117
2.1.2 (2025-11-11)

handoff-boundary-protocol-rethink.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,23 @@ port. Phase E (anyio-native core) becomes optional purity/perf work.
128128
in the first waiter's hub (carriers may be constructed on the host
129129
loop); tests behind the `gevent` dependency group.
130130

131-
Next: Phase C `loop=main` / `exec=task` on the smaller core
132-
(handoff-phase-c-worker-axes.md), and Phase D docs cover the four
133-
namespaces + the axes/presets.
131+
## SUPERSEDED 2026-07-29 by the surface review
132+
133+
Phase C landed, and reviewing the whole public surface at once (before
134+
Phase D docs froze it) retired several of the decisions above. See
135+
"Surface review" in `handoff-phase-c-worker-axes.md` for what stands. In
136+
short:
137+
138+
- **The Wakener extension point is gone.** There was never a plan to let
139+
third parties add event loops, and the asyncio wakener sketched here was
140+
deliberately never built (P4 chose per-call bridging). What is left is
141+
two wait backends, threads and gevent, behind a two-branch
142+
`make_wakener`. `execnet.portal` is now private `execnet._portal`.
143+
- **`wait=` is gone as a spec key.** It described the *caller's*
144+
concurrency library, which is what picking a namespace already says.
145+
gevent got the facade it was missing: `execnet.gevent`.
146+
- **The `loop=` / `exec=` axes stayed dropped**; `execmodel=` became
147+
`profile=` (see below).
134148

135149
## Invariants (unchanged, re-mapped)
136150

handoff-phase-c-worker-axes.md

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,63 @@ pytest testing/ -n 12` passes (~7s) since the session-attach race fix
1717
`testing/test_ssh_local.py` (asyncssh server; system ssh client needed).
1818
Known flake: `test_socket_installvia` EOFs rarely under load.
1919

20+
## Surface review — LANDED 2026-07-29 (`7aa17fe..e75cd0a`)
21+
22+
The public surface had settled commit by commit and was never reviewed as
23+
a whole. Doing that before the Phase D docs froze it produced the
24+
following, which **wins over anything below or in
25+
`handoff-boundary-protocol-rethink.md` that contradicts it**.
26+
27+
**Namespaces are now one per concurrency library you drive execnet from**:
28+
`execnet.sync` (threads; the top-level aliases), `execnet.trio`,
29+
`execnet.aio`, `execnet.gevent`. `execnet.portal` is gone --
30+
`execnet._portal` plus the trio-free `execnet._boundary`.
31+
32+
| was | is | why |
33+
|---|---|---|
34+
| `execnet.portal` public | `execnet._portal` private | it exported `Wakener`/`Mailbox`/`OneShot`/`LoopPortal` but *not* `register_wakener`, so the advertised extension point was unreachable -- and there is no plan to let third parties add event loops at all |
35+
| Wakener registry (`register_wakener`, lazy module table) | two-branch `make_wakener(Literal["thread","gevent"])` | exactly two backends exist; every other library gets a facade |
36+
| `wait=` spec key | gone; `Group._wait_backend`, set by the facade | it described the *caller*, which the namespace already says. Worker-side wait was always derived from the profile |
37+
| gevent via `wait=gevent` | `execnet.gevent.Group` | symmetric with the other surfaces |
38+
| `execmodel=` spec key | `profile=` (`execmodel=` a permanent alias) | the key selects the *worker profile*; the local execution model it was named after no longer exists |
39+
| `Group(execmodel=)`, `set_execmodel`, `group.execmodel` | deprecated; only the remote default survives | they had no behavioural effect. **xdist passes `Group(execmodel=...)` as a keyword** -- that must keep working |
40+
| `main_thread_only` profile | deprecated alias for `thread` | `HybridExec` already gives the first remote_exec the real main thread. Its extra behaviour (refusing a second concurrent remote_exec) was a 1s-timeout deadlock guard, now deleted |
41+
| one `TrioHost` per `Group` | one shared `execnet.Host` per process, `Group(host=...)` to override | a host is a thread and a loop, not something groups need isolated |
42+
| blocking inside a running loop hangs | raises, naming `execnet.aio` / `execnet.trio` | worker-side channels stay exempt: exec'd code may run its own loop |
43+
| `aio.Group`/`Gateway`/`Channel` | `aio.AsyncGroup`/`AsyncGateway`/`AsyncChannel` | matches `execnet.trio`; swapping the import ports the code |
44+
| `open_popen_gateway` | `open_gateway` (both async surfaces) | it always accepted any spec |
45+
46+
Behaviour changes worth a changelog line:
47+
48+
- a second concurrent `remote_exec` under `main_thread_only` used to close
49+
the channel with `MAIN_THREAD_ONLY_DEADLOCK_TEXT`; it now runs on a pool
50+
thread. `_executetask_complete`, `MAIN_THREAD_ONLY_ADMIT_TIMEOUT` and
51+
the error text are deleted; `MainExec` became `PrimaryThreadPump`.
52+
- `execnet.aio` cancellation is now real: a cancelled `receive` cancels
53+
the host-side operation instead of consuming and discarding an item.
54+
`send`/`send_eof`/`aclose`/`terminate` are shielded instead.
55+
- the boundary carriers raise `execnet.TimeoutError`, not the builtin;
56+
`OneShot` double-resolve is a `RuntimeError`, not an `assert`.
57+
- `STATUS` answers both `profile` and (legacy) `execmodel`.
58+
- **Latent livelock fixed**: `execnet.dumps` warned on *every* access, and
59+
xdist calls it from `serialize_warning_message` -- i.e. from inside
60+
pytest's warning-recording hook. One DeprecationWarning in a worker
61+
therefore recorded a warning that recorded a warning, unbounded, and
62+
wedged the run. The shim warns once per process
63+
(`execnet._xdist_compat_warned`). Anything that warns in a worker can
64+
hit this class of bug; keep it in mind.
65+
66+
New tests: `testing/test_host.py` (sharing, explicit `Host`, fork, the
67+
loop guards), `testing/test_boundary.py` (renamed from `test_portal.py`),
68+
aio cancellation contracts in `testing/test_aio.py`. The `execmodel`
69+
fixture parametrization collapsed to a single `profile` fixture, so the
70+
suite is ~540 items rather than ~765.
71+
72+
Still open from the review, deliberately not done: the async surfaces have
73+
no `remote_status()`, no `MultiChannel`, no group iteration, and no
74+
`RSync`. `AsyncGroup.makegateway` defaults workers to the `thread`
75+
profile (the coordinator's shape does not dictate the worker's).
76+
2077
## Where the repo stands (2026-07-25, after `a69b844`)
2178

2279
One protocol engine: `AsyncGateway` (`_trio_gateway.py`). The sync API
@@ -45,12 +102,12 @@ File map (src/execnet/):
45102

46103
| file | role |
47104
|---|---|
48-
| `gateway_base.py` | `Message` wire protocol + sans-IO `FrameDecoder`, serializer (CHANNEL opcode incl. duck-typed `save_AsyncChannel`), sync `Channel`/`ChannelFactory` (raw-receiver registry), `BaseGateway`/`WorkerGateway` (`_send` via bridge, `_send_nonblocking` for GC), `ExecModel`, `WorkerPool`, `HostNotFound` |
105+
| `_message.py` / `_serialize.py` / `_channel.py` / `_gateway_base.py` / `_errors.py` / `_execmodel.py` | split by concern since this table was written: wire protocol + sans-IO `FrameDecoder`; serializer (CHANNEL opcode incl. duck-typed `save_AsyncChannel`); sync `Channel`/`ChannelFactory`; `BaseGateway`/`WorkerGateway`; error types; `WORKER_PROFILES` + `resolve_profile` + the deprecated `ExecModel` xdist shim. `gateway_base.py` is now only a warning shim |
49106
| `_trio_gateway.py` | async core: `ByteStream` Protocol, `RawChannel`/`AsyncChannel`, `AsyncGateway` (outbound queue of `(frame, on_written)`; `_finalize` hook), `AsyncGroup` (all transports, overridable `_make_gateway`/`_open_via_stream`/`_resolve_socket_address`, reapers, bounded terminate), stream/argv helpers |
50107
| `_trio_host.py` | `TrioHost` (loop thread), `SyncBridgeGateway`, `FacadeAsyncGroup`, `makegateway_trio`, `SyncIOHandle`, `RawTunnelStream` (via tunnel; `aclose` feeds own reader EOF), `GATEWAY_START_*` handlers |
51108
| `_trio_worker.py` | worker entry (`_main`/`serve_popen_trio`/`serve_socket_trio`), `TrioWorkerExec` (FIFO `_pump` admission; `integrate_as_primary_thread`), `_prepare_protocol_fds`, `_check_version` |
52109
| `gateway.py` / `multi.py` | sync `Gateway` / sync `Group` facade, `MultiChannel`, `safe_terminate` (WorkerPool-based, kept for tests) |
53-
| `sync.py` / `trio.py` / `portal.py` | the three public namespaces |
110+
| `sync.py` / `trio.py` / `aio.py` / `gevent.py` | the four public namespaces; `_host.py` holds the shared `Host`, `_portal.py`/`_boundary.py` the (private) boundary kit |
54111
| `_exec_source.py`, `_provision.py`, `xspec.py`, `rsync.py` | source normalization, uv provisioning + argv builders, spec parsing, rsync |
55112

56113
## Semantics that MUST survive (xdist depends on them)
@@ -84,20 +141,23 @@ different use-cases get named profiles, `execmodel=` is the public mode
84141
key (xdist already passes it), and `wait=` is the only other public
85142
knob. Implemented in commits `b2f43c3..cbce183`:
86143

87-
| `execmodel=` | loop thread | exec'd code runs | channel | worker wait | extra deps |
144+
| `profile=` (was `execmodel=`) | loop thread | exec'd code runs | channel | worker wait | extra deps |
88145
|---|---|---|---|---|---|
89146
| `thread` (default) | side thread | **classic hybrid restored**: primary on the main thread, overflow on pool threads (claim decided during FIFO admission) | sync | thread ||
90-
| `main_thread_only` | side thread | main thread, serialized (deadlock guard) | sync | thread | |
147+
| ~~`main_thread_only`~~ | *deprecated 2026-07-29, aliases to `thread`* | | | | |
91148
| `trio` (new) | **main thread** | async sources as tasks — one single thread total; top-level await or async def; sync sources rejected; termination cancels tasks | AsyncChannel | (loop) ||
92149
| `gevent` (revived) | side thread | greenlets on a main-thread hub, one per remote_exec | sync | gevent (derived) | `execnet[gevent]`, auto-added by uv provisioning |
93150

151+
(The coordinator-side counterpart of the last row is now `execnet.gevent`,
152+
not `wait=gevent`.)
153+
94154
Architecture: `TrioWorkerExec` is a pure FIFO admission pump delegating
95155
to strategy objects (`WORKER_EXEC_STRATEGIES` in `_trio_worker.py`:
96156
PoolExec building block, MainExec, HybridExec, GreenletExec; TaskExec
97157
serves a plain AsyncGateway via its pluggable `_exec_handler` — no sync
98158
bridge at all in the trio profile). Subinterpreters: future strategy
99-
slot, not built. `EXECMODEL_PROFILES` (gateway_base) validates
100-
coordinator-side in makegateway.
159+
slot, not built. `WORKER_PROFILES` (`_execmodel.py`) validates
160+
coordinator-side in makegateway, via `resolve_profile`.
101161

102162
**Native info/setup** (the pytest fix): `Message.GATEWAY_INFO` (code 10)
103163
answers `_rinfo()` from the dispatch loop; chdir/nice/env ship in the
@@ -108,9 +168,9 @@ post-start remote_exec setup block are gone.
108168

109169
Coordinator-gevent integration: `TrioHost.call_pending` (OneShot from a
110170
host task) backs makegateway / `SyncIOHandle.wait/kill` /
111-
`Group.terminate` whenever the wait backend is not `thread`, so a gevent
112-
app's management ops park only the calling greenlet. `wait=thread`
113-
keeps the KI-deferred `portal.run` path.
171+
`Group.terminate` whenever the group's wait backend is not `thread`, so a
172+
gevent app's management ops park only the calling greenlet. The default
173+
`thread` backend keeps the KI-deferred `portal.run` path.
114174

115175
Still decided/standing: core stays trio-only (anyio/asyncio-core port
116176
rejected; asyncio apps use `execnet.aio`); eventlet stays dead; exec'd

src/execnet/_execmodel.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,7 @@ def resolve_profile(name: str) -> str:
110110
)
111111
return replacement
112112
if name not in WORKER_PROFILES:
113-
raise ValueError(
114-
f"unknown profile {name!r} (known: {list(WORKER_PROFILES)})"
115-
)
113+
raise ValueError(f"unknown profile {name!r} (known: {list(WORKER_PROFILES)})")
116114
return name
117115

118116

src/execnet/_host.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@
2727
import sys
2828
import threading
2929
from types import TracebackType
30+
from typing import TYPE_CHECKING
3031
from typing import Any
3132

33+
if TYPE_CHECKING:
34+
from typing_extensions import Self
35+
3236
__all__ = ["Host", "check_not_in_event_loop", "default_host"]
3337

3438
#: default cap on concurrent threadpool threads running receiver callbacks
@@ -142,7 +146,7 @@ def close(self, timeout: float | None = 5.0) -> None:
142146
if trio_host is not None:
143147
trio_host.stop(timeout=timeout)
144148

145-
def __enter__(self) -> Host:
149+
def __enter__(self) -> Self:
146150
return self
147151

148152
def __exit__(

src/execnet/_multi.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,7 @@ def set_execmodel(
167167
DeprecationWarning,
168168
stacklevel=2,
169169
)
170-
self.set_profile(
171-
execmodel if remote_execmodel is None else remote_execmodel
172-
)
170+
self.set_profile(execmodel if remote_execmodel is None else remote_execmodel)
173171

174172
def __repr__(self) -> str:
175173
idgateways = [gw.id for gw in self]
@@ -273,9 +271,7 @@ def _cleanup_atexit(self) -> None:
273271
self.terminate(timeout=1.0)
274272
if self._async_group is not None:
275273
with suppress(Exception):
276-
self._host._ensure_started().call_sync(
277-
self._async_group.shutdown.set
278-
)
274+
self._host._ensure_started().call_sync(self._async_group.shutdown.set)
279275
self._async_group = None
280276

281277
def terminate(self, timeout: float | None = None) -> None:

src/execnet/_trio_gateway.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@
4242
from ._errors import HostNotFound
4343
from ._errors import RemoteError
4444
from ._errors import TimeoutError
45-
from ._execmodel import resolve_profile
4645
from ._exec_source import normalize_exec_source
46+
from ._execmodel import resolve_profile
4747
from ._message import FrameDecoder
4848
from ._message import Message
4949
from ._message import gateway_info

src/execnet/_trio_host.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@
3232
from ._errors import RemoteError
3333
from ._execmodel import ExecModel
3434
from ._execmodel import get_execmodel
35+
from ._host import DEFAULT_CALLBACK_THREADS
3536
from ._message import FrameDecoder
3637
from ._message import Message
3738
from ._message import gateway_info
39+
from ._portal import LoopPortal
40+
from ._portal import OneShot
3841
from ._serialize import dumps_internal
3942
from ._serialize import loads_internal
4043
from ._trace import trace
@@ -46,9 +49,6 @@
4649
from ._trio_gateway import open_popen_process
4750
from ._trio_gateway import read_handshake_ack
4851
from ._trio_gateway import ssh_transport_args
49-
from ._host import DEFAULT_CALLBACK_THREADS
50-
from ._portal import LoopPortal
51-
from ._portal import OneShot
5252

5353
#: bound on how long the endmarker callback may run during host shutdown
5454
CONSUMER_ENDMARKER_GRACE = 10.0

0 commit comments

Comments
 (0)