Skip to content

Commit 72b0eef

Browse files
docs: hand off phase C/D — worker axes gap analysis, supersede the B doc
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a69b844 commit 72b0eef

2 files changed

Lines changed: 147 additions & 3 deletions

File tree

handoff-phase-b-async-core.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Handoff: Phase B — invert execnet onto an async-native Trio core
22

3-
For a fresh session on branch `feat/trio-host-thread-io`. **Phase B is
4-
complete (B.1–B.6)**; work continues at **Phase C**. Plan context lives in
5-
session memory (`trio-port-plan`), but everything needed is restated here.
3+
**SUPERSEDED by `handoff-phase-c-worker-axes.md`** — kept as the Phase B
4+
record. **Phase B is complete (B.1–B.6)** on branch
5+
`feat/trio-host-thread-io`; work continues at **Phase C**.
66

77
Run checks with `uv run pytest testing/` and `uv run pre-commit run -a`
88
(never grep-filter pre-commit output). ssh paths have a real local harness
@@ -123,6 +123,11 @@ unchanged (508 passed). What landed, and the decisions taken:
123123
- Gotcha fixed on the way: a stream-closing `aclose` on the via tunnel
124124
(`RawTunnelStream`) must feed EOF to its own reader, else the bridge's
125125
serve task never finishes and terminate hangs.
126+
- Post-B fix (`51b9053`): the session-attach startup race — the bridge
127+
must attach itself to the sync gateway in `__init__`, before serving
128+
starts, or a first-message reply goes through the IO stub and kills
129+
the fresh gateway (predated B.5; surfaced by `pytest -n 12`). The
130+
suite now runs xdist-clean.
126131
- `ChannelFile`/`makefile`: kept the existing str-based `gateway_base`
127132
classes as-is for backward compat (they only use channel send/receive).
128133
- NOT yet retired: `ExecModel` internals and `WorkerPool` (still the

handoff-phase-c-worker-axes.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Handoff: Phase C — worker config axes (`loop=` × `exec=`) + Phase D
2+
3+
For a fresh session on branch `feat/trio-host-thread-io`. Phase B (the
4+
inversion onto the async-native Trio core) is **complete**; this doc
5+
supersedes `handoff-phase-b-async-core.md` (kept for the B record).
6+
Plan context lives in session memory (`trio-port-plan`), but everything
7+
needed is restated here.
8+
9+
Run checks with `uv run pytest testing/` and `uv run pre-commit run -a`
10+
(never grep-filter pre-commit output). The suite is xdist-clean: `uv run
11+
pytest testing/ -n 12` passes (~7s) since the session-attach race fix
12+
(`51b9053`) — keep it that way. ssh paths have a real local harness in
13+
`testing/test_ssh_local.py` (asyncssh server; system ssh client needed).
14+
Known flake: `test_socket_installvia` EOFs rarely under load.
15+
16+
## Where the repo stands (2026-07-25, after `a69b844`)
17+
18+
One protocol engine: `AsyncGateway` (`_trio_gateway.py`). The sync API
19+
is a facade over it — coordinator and worker sessions are
20+
`SyncBridgeGateway` (an `AsyncGateway` subclass in `_trio_host.py`) whose
21+
`_dispatch` runs the classic sync `Message.received` handlers under
22+
`_receivelock`; `SyncBridgeGateway.__init__` attaches itself to the sync
23+
gateway *before* serving starts (the fix for the startup race where a
24+
STATUS/CHANNEL_EXEC arriving first replied through the IO stub and killed
25+
the session). `multi.Group` owns a `FacadeAsyncGroup` task on its
26+
`TrioHost`; makegateway and bounded process shutdown delegate to
27+
`AsyncGroup`, while `terminate` keeps the member `exit()`/`join()`
28+
contract (pinned by `test_basic_group`). `AsyncGroup` supports every
29+
transport (popen, `python=`, ssh, vagrant_ssh, socket with installvia,
30+
`via=`). Public namespaces: `execnet.sync` (top-level `execnet.*`
31+
aliases into it), `execnet.trio`, `execnet.portal` (trio/portal lazy via
32+
module `__getattr__`; `import execnet` must not import trio — pinned by
33+
`testing/test_namespaces.py`).
34+
35+
No source is shipped over the wire: workers run
36+
`python -m execnet._trio_worker <config-json>`; foreign/remote
37+
interpreters are uv-provisioned via `_provision.py`; dev coordinators
38+
ship a wheel.
39+
40+
File map (src/execnet/):
41+
42+
| file | role |
43+
|---|---|
44+
| `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` |
45+
| `_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 |
46+
| `_trio_host.py` | `TrioHost` (loop thread), `SyncBridgeGateway`, `FacadeAsyncGroup`, `makegateway_trio`, `SyncIOHandle`, `RawTunnelStream` (via tunnel; `aclose` feeds own reader EOF), `GATEWAY_START_*` handlers |
47+
| `_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` |
48+
| `gateway.py` / `multi.py` | sync `Gateway` / sync `Group` facade, `MultiChannel`, `safe_terminate` (WorkerPool-based, kept for tests) |
49+
| `sync.py` / `trio.py` / `portal.py` | the three public namespaces |
50+
| `_exec_source.py`, `_provision.py`, `xspec.py`, `rsync.py` | source normalization, uv provisioning + argv builders, spec parsing, rsync |
51+
52+
## Semantics that MUST survive (xdist depends on them)
53+
54+
- Sends from non-loop threads block until the frame hit the OS write
55+
(120s → OSError) so abrupt `os._exit` cannot drop "sent" data; loop
56+
thread sends only enqueue. All sends go through one portal-posted FIFO.
57+
- After close: `OSError("cannot send (already closed?)")`;
58+
`trio.RunFinishedError` maps to the same.
59+
- exec admission order = message arrival order (`TrioWorkerExec._pump`;
60+
`test_main_thread_only_concurrent_remote_exec_deadlock` guards this —
61+
trio shuffles its run batch, so never rely on task-spawn order).
62+
- Channel callbacks run on the receiver (loop) thread — keep for now
63+
(open decision: revisit in D).
64+
- `Group.terminate(timeout)` never hangs (~2×timeout bound, issues
65+
#43/#221).
66+
- Sync blocking waits (send-ack, receive, waitclose, join) stay on
67+
`threading.Event`/`queue.Queue` so KeyboardInterrupt can interrupt
68+
them; `portal.run` (KI-deferred) is only for management ops.
69+
70+
## Phase C — what is missing (nothing of C exists yet)
71+
72+
Target axes: `loop=thread|main` × `exec=thread|main|task`.
73+
Compat mapping: `execmodel=thread``loop=main` + `exec=thread`;
74+
`execmodel=main_thread_only``loop=thread` + `exec=main`.
75+
76+
1. **Spec surface**: `xspec.py` only knows `execmodel=`. Add `loop=` /
77+
`exec=` keys, combination validation, and the compat mapping. Same
78+
for the worker CLI config JSON (`_provision.worker_cli_arg` still
79+
ships `{"id", "execmodel", "coordinator_version"}`).
80+
2. **`loop=main` does not exist**: the worker always starts `TrioHost`
81+
on a dedicated thread (`serve_popen_trio`), main thread parked in
82+
`gateway.join()` or integrated as exec primary. The compat mapping
83+
means plain `execmodel=thread` workers should end up with `trio.run`
84+
on the main thread — a structural change to `_run_worker`, not a
85+
flag.
86+
3. **`exec=main`** is today's `main_thread_only` machinery
87+
(`integrate_as_primary_thread` + `_executetask_complete` deadlock
88+
guard) — keep behavior, re-home under the new naming.
89+
4. **`exec=task` (async remote_exec)**: `CHANNEL_EXEC` on a pure
90+
`AsyncGateway` is still rejected ("unsupported message" in
91+
`_trio_gateway._dispatch`). Needs a task-based exec scheduler
92+
handing exec'd code an `AsyncChannel`, preserving FIFO admission,
93+
plus explicit cancellation semantics.
94+
5. **STATUS / `remote_status()`** reports `execmodel`; must speak the
95+
new axes compatibly.
96+
6. **Retire `ExecModel`/`WorkerPool`** (deferred from B): replace
97+
internals with plain `threading`/`queue`; deprecated shims for
98+
`group.execmodel`/`set_execmodel`/`spec.execmodel`. Pinned today by
99+
`multi.safe_terminate`, `testing/test_threadpool.py`, conftest's
100+
`pool` fixture, `Channel._items` (`execmodel.queue`), and the worker
101+
STATUS duck-type — gated on the compat mapping landing first.
102+
7. **Wheel-on-demand for `GATEWAY_START_SUB`**: recorded TODO in
103+
`_provision.spawn_request` (currently ships eagerly whenever the
104+
sub-spec has `python=`/`ssh=`).
105+
106+
## Phase D — what is missing
107+
108+
1. **Docs are entirely stale**: `doc/` still describes source
109+
bootstrapping and execmodels; nothing on uv provisioning, the
110+
no-source-shipping/version-compat policy, the three namespaces, or
111+
`execnet.trio`/`execnet.portal` APIs. `doc/implnotes.rst` references
112+
pre-inversion internals. Changelog entries for the whole branch.
113+
2. **Trio-surface test gaps**: async ssh/socket/vagrant transport tests
114+
(the asyncssh harness only exercises the sync facade; both share
115+
`connect_command_worker`, so a direct `AsyncGroup` ssh test is
116+
cheap), cancellation mid-`remote_exec`/`receive`, `send_eof` and
117+
reconfigure against real workers, B.5 engine paths (write-ack
118+
failure, `enqueue_message` after close).
119+
3. **pytest-xdist verification**: run pytest-xdist's own suite against
120+
this branch plus real `-n` smoke runs (crash/endmarker tests,
121+
`main_thread_only` GUI case). Our own suite running `-n 12` green is
122+
necessary but not sufficient.
123+
4. **Close the open decision**: channel callbacks on the loop thread —
124+
keep or move.
125+
126+
Recommended order: C.1+C.2 first (spec axes + loop placement) since the
127+
compat mapping unblocks the ExecModel retirement, then `exec=task`; run
128+
the xdist verification (D.3) mid-C as an early canary rather than only
129+
at the end.
130+
131+
## Invariants (do not regress)
132+
133+
- No source shipping, ever. Workers import installed execnet+trio;
134+
rough major/minor version check (`_trio_worker._check_version`) warns.
135+
- Worker teardown: `_trio_worker._run_worker` ends with `os._exit(0)`
136+
because trio's `to_thread` cache uses non-daemon threads.
137+
- `import execnet` must not import the trio event loop.
138+
- Keep async-core idioms anyio-portable (neutral `ByteStream`, sans-IO
139+
`FrameDecoder`) — `execnet.anyio` is deferred Phase E.

0 commit comments

Comments
 (0)