|
| 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