Compare commits
142 Commits
d9a6e2e9b4
...
85a44588cf
| Author | SHA1 | Date |
|---|---|---|
|
|
85a44588cf | |
|
|
647a856ee7 | |
|
|
a5ee4cfd4f | |
|
|
7d66510cdb | |
|
|
19f807ab18 | |
|
|
198f2985ba | |
|
|
23bb74bad6 | |
|
|
bbf93eab4e | |
|
|
521d0485e3 | |
|
|
4be23acce2 | |
|
|
69f3aa807c | |
|
|
d852b11323 | |
|
|
a1e5204ef9 | |
|
|
40590c64dc | |
|
|
ae10a3ae56 | |
|
|
90c4954cb9 | |
|
|
0664731372 | |
|
|
e90f2f224a | |
|
|
1ad6281348 | |
|
|
b3e8ed18d1 | |
|
|
d5e66dffaa | |
|
|
cf56f33be6 | |
|
|
8739c5fadb | |
|
|
dfdaf2b1c1 | |
|
|
37eeb7bab6 | |
|
|
1d59f1963c | |
|
|
dd3e7482bf | |
|
|
266073cb69 | |
|
|
4134726ec4 | |
|
|
09e78ad087 | |
|
|
dd91195377 | |
|
|
e7f5968850 | |
|
|
551090d129 | |
|
|
bb0a9b3c93 | |
|
|
5d70959a2b | |
|
|
e8f636ddbb | |
|
|
4eca8d7a10 | |
|
|
3fcc4ee713 | |
|
|
de3dc2ded0 | |
|
|
f81f7d40c3 | |
|
|
f4f3034555 | |
|
|
5420b13482 | |
|
|
e824e6b768 | |
|
|
0835963fa2 | |
|
|
4e4191701c | |
|
|
19d0e9cb78 | |
|
|
8df118dde8 | |
|
|
e328b4d729 | |
|
|
3a6bff0b6e | |
|
|
61ad5bd158 | |
|
|
34e638863e | |
|
|
d6da42984f | |
|
|
916655996c | |
|
|
601d92eb4a | |
|
|
b7298e6507 | |
|
|
5f49544cf3 | |
|
|
74cbee6c69 | |
|
|
f136063239 | |
|
|
80ce54aeb1 | |
|
|
98bc642e72 | |
|
|
48844aa4ac | |
|
|
760abc8268 | |
|
|
bf46f5cc5e | |
|
|
773370a423 | |
|
|
a99a4c9353 | |
|
|
b58889f625 | |
|
|
0a580df63d | |
|
|
617ca1de43 | |
|
|
9373e9434d | |
|
|
ce430fca64 | |
|
|
e42ecb559d | |
|
|
5327b25e1b | |
|
|
2f86dd1a33 | |
|
|
88d538e3a6 | |
|
|
ce38cb6f0e | |
|
|
5c4859860a | |
|
|
23e26b5b32 | |
|
|
a849161fa5 | |
|
|
086962ca36 | |
|
|
1029b81dfc | |
|
|
40d2d9942e | |
|
|
04123019b7 | |
|
|
643e1c861b | |
|
|
57febf045d | |
|
|
20e89334c0 | |
|
|
fe0a724d10 | |
|
|
99be161ec0 | |
|
|
c294a812c3 | |
|
|
17d7341334 | |
|
|
ecf89bfaac | |
|
|
49213d170e | |
|
|
b69a8667d4 | |
|
|
3aaa3bccbd | |
|
|
3b0b37dcb4 | |
|
|
3690e43abc | |
|
|
33d74da8c2 | |
|
|
9afda1c61d | |
|
|
a3d65a6b3d | |
|
|
4e27dcde48 | |
|
|
93322405a1 | |
|
|
359fe75ced | |
|
|
40be587ce2 | |
|
|
ca4582b003 | |
|
|
75385e448d | |
|
|
34d81adef3 | |
|
|
1691be96fd | |
|
|
36ad1f3dd0 | |
|
|
72441124c0 | |
|
|
0cbb850645 | |
|
|
8f0df0ff6f | |
|
|
f75c9cdeab | |
|
|
75cda1933c | |
|
|
f454cefe56 | |
|
|
d0cc06815f | |
|
|
ebf2258b4f | |
|
|
ac61d7a5bf | |
|
|
584ea4e9ad | |
|
|
16dd876b0c | |
|
|
1a9ce915f3 | |
|
|
0b63af020e | |
|
|
6e424d4696 | |
|
|
5724c0516a | |
|
|
0d6d7c2a63 | |
|
|
bd38204fde | |
|
|
340d506940 | |
|
|
64e820e18e | |
|
|
0a3b0efcc6 | |
|
|
634d914161 | |
|
|
451e0acf8a | |
|
|
3d02a8569e | |
|
|
bceca74eb7 | |
|
|
18faffcff7 | |
|
|
7554f90e59 | |
|
|
3705bbe594 | |
|
|
66ac7863b5 | |
|
|
089e158da9 | |
|
|
7987f6b8d7 | |
|
|
70c7e334a7 | |
|
|
62b729a106 | |
|
|
213a298aad | |
|
|
d1d3fc58bb | |
|
|
a2c8af558e |
|
|
@ -91,6 +91,10 @@ jobs:
|
||||||
name: '${{ matrix.os }} Python${{ matrix.python-version }} spawn_backend=${{ matrix.spawn_backend }} tpt_proto=${{ matrix.tpt_proto }}'
|
name: '${{ matrix.os }} Python${{ matrix.python-version }} spawn_backend=${{ matrix.spawn_backend }} tpt_proto=${{ matrix.tpt_proto }}'
|
||||||
timeout-minutes: 16
|
timeout-minutes: 16
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
# Windows support is nascent: its full test suite remains
|
||||||
|
# informational, while setup and the `import tractor` smoke below
|
||||||
|
# are hard signals. Promote the test step to required once the
|
||||||
|
# suite is green.
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
@ -98,6 +102,7 @@ jobs:
|
||||||
os: [
|
os: [
|
||||||
ubuntu-latest,
|
ubuntu-latest,
|
||||||
macos-latest,
|
macos-latest,
|
||||||
|
windows-latest,
|
||||||
]
|
]
|
||||||
python-version: [
|
python-version: [
|
||||||
'3.13',
|
'3.13',
|
||||||
|
|
@ -118,10 +123,10 @@ jobs:
|
||||||
'tcp',
|
'tcp',
|
||||||
'uds',
|
'uds',
|
||||||
]
|
]
|
||||||
# https://github.com/orgs/community/discussions/26253#discussioncomment-3250989
|
|
||||||
exclude:
|
exclude:
|
||||||
# don't do UDS run on macOS (for now)
|
# UDS is POSIX-only; Windows has no `AF_UNIX` so the
|
||||||
- os: macos-latest
|
# backend is intentionally unavailable there.
|
||||||
|
- os: windows-latest
|
||||||
tpt_proto: 'uds'
|
tpt_proto: 'uds'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -150,7 +155,18 @@ jobs:
|
||||||
- name: List deps tree
|
- name: List deps tree
|
||||||
run: uv tree
|
run: uv tree
|
||||||
|
|
||||||
|
# hard signal for the Windows import-safety fix: `import
|
||||||
|
# tractor` must succeed everywhere, and `HAS_UDS` reflects
|
||||||
|
# platform capability (False on Windows, True on POSIX).
|
||||||
|
- name: 'Smoke: import tractor'
|
||||||
|
run: uv run python -c "import sys; import tractor; from tractor.ipc._uds import HAS_UDS; assert sys.platform != 'win32' or not HAS_UDS; print('import tractor OK | HAS_UDS=', HAS_UDS)"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
# Actor/PTY scheduling on macOS can fail a different
|
||||||
|
# timing-sensitive node between otherwise-green runs. Retry
|
||||||
|
# only that matrix leg; deterministic failures still fail
|
||||||
|
# after the final attempt.
|
||||||
|
continue-on-error: ${{ matrix.os == 'windows-latest' }}
|
||||||
run: >
|
run: >
|
||||||
uv run
|
uv run
|
||||||
pytest
|
pytest
|
||||||
|
|
@ -159,6 +175,8 @@ jobs:
|
||||||
--spawn-backend=${{ matrix.spawn_backend }}
|
--spawn-backend=${{ matrix.spawn_backend }}
|
||||||
--tpt-proto=${{ matrix.tpt_proto }}
|
--tpt-proto=${{ matrix.tpt_proto }}
|
||||||
--capture=fd
|
--capture=fd
|
||||||
|
--reruns=${{ matrix.os == 'macos-latest' && 2 || 0 }}
|
||||||
|
--reruns-delay=1
|
||||||
|
|
||||||
# XXX legacy NOTE XXX
|
# XXX legacy NOTE XXX
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,463 @@
|
||||||
|
# `_ria_nursery` removal plan (issue #477 follow-up)
|
||||||
|
|
||||||
|
Goal: drop the secondary "run-in-actor" spawn nursery (and
|
||||||
|
friends) from `ActorNursery`/spawn internals, now that
|
||||||
|
`tractor.to_actor.run()` delivers one-shot semantics purely on
|
||||||
|
the daemon-spawn + portal primitives.
|
||||||
|
|
||||||
|
## Verified machinery map (2026-07-02, wkt @ a34aaf98)
|
||||||
|
|
||||||
|
The entire mechanism is 4 files:
|
||||||
|
|
||||||
|
- `runtime/_supervise.py`
|
||||||
|
- `ActorNursery.__init__(.., ria_nursery, ..)` stores
|
||||||
|
`._ria_nursery` (:202, :238); sole read is
|
||||||
|
`run_in_actor()` passing `nursery=self._ria_nursery`
|
||||||
|
(:442) into `start_actor()`'s `nursery:
|
||||||
|
trio.Nursery|None` escape-hatch param (:305, :367).
|
||||||
|
- `._cancel_after_result_on_exit: set` (:244) marks ria
|
||||||
|
portals (:457).
|
||||||
|
- `_open_and_supervise_one_cancels_all_nursery()` nests
|
||||||
|
`da_nursery` (:609) around `ria_nursery` (:622); the
|
||||||
|
`finally:` at the ria->da boundary (:747-766) raises
|
||||||
|
collected `errors` (single exc or BEG).
|
||||||
|
- `runtime/_portal.py`
|
||||||
|
- `._expect_result_ctx` (:112) set by `_submit_for_result()`
|
||||||
|
(:142, sole caller `run_in_actor()`); consumed by
|
||||||
|
`wait_for_result()` (:167) + deprecated `result()` (:220).
|
||||||
|
The `None` branch (:184-196) returns the `NoResult`
|
||||||
|
sentinel (`_exceptions.py:1164`).
|
||||||
|
- `spawn/_spawn.py`
|
||||||
|
- `exhaust_portal()` (:129): awaits
|
||||||
|
`portal.wait_for_result()`, CATCHES+RETURNS any exc
|
||||||
|
(never raises).
|
||||||
|
- `cancel_on_completion()` (:177): `exhaust_portal()` ->
|
||||||
|
on exc-result stash `errors[uid] = result` (:203) ->
|
||||||
|
ALWAYS `portal.cancel_actor()` (:218).
|
||||||
|
- `spawn/_trio.py` (:195-222) + `spawn/_mp.py` (:187-213),
|
||||||
|
identical shape: after shielded
|
||||||
|
`await an._join_procs.wait()`, open a per-child local
|
||||||
|
nursery; IFF `portal in an._cancel_after_result_on_exit`
|
||||||
|
start `cancel_on_completion` alongside `soft_kill()`; when
|
||||||
|
`soft_kill` returns first, `nursery.cancel_scope.cancel()`
|
||||||
|
reaps the result-waiter.
|
||||||
|
|
||||||
|
## The load-bearing semantic (already-deferred errors)
|
||||||
|
|
||||||
|
Remote ria-child errors NEVER raise into `ria_nursery`:
|
||||||
|
|
||||||
|
1. reaper tasks only START after `_join_procs.set()` (block
|
||||||
|
exit or the inner error handler),
|
||||||
|
2. `exhaust_portal` swallows the exc into a return value,
|
||||||
|
3. `cancel_on_completion` stashes it in `errors` + cancels
|
||||||
|
that child,
|
||||||
|
4. the ria->da `finally:` re-raises collected `errors` (and
|
||||||
|
`an.cancel()`s any daemon stragglers).
|
||||||
|
|
||||||
|
So mid-block there is NO error propagation from ria children
|
||||||
|
(unless user code explicitly `await portal.wait_for_result()`s)
|
||||||
|
— the two-nursery nesting only sequences "reap ria results
|
||||||
|
BEFORE blocking on daemon join". A single-nursery impl only
|
||||||
|
needs to preserve that sequencing, not any ASAP-cancel
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
## Target design
|
||||||
|
|
||||||
|
### step A: single-nursery `run_in_actor()` (mechanical)
|
||||||
|
|
||||||
|
- `run_in_actor()` spawns via the DEFAULT (`_da_nursery`)
|
||||||
|
path — drop `nursery=self._ria_nursery`.
|
||||||
|
- rename `._cancel_after_result_on_exit` ->
|
||||||
|
`._ria_portals: dict[portal, Actor]` (need the subactor ref
|
||||||
|
for `cancel_on_completion`).
|
||||||
|
- move reaper start-up OUT of the backends into
|
||||||
|
`_open_and_supervise...`: immediately after EACH
|
||||||
|
`an._join_procs.set()` call-site (happy path :642, inner
|
||||||
|
error handler :661), start one
|
||||||
|
`cancel_on_completion(portal, subactor, errors)` task per
|
||||||
|
ria portal into `da_nursery`, then (happy path only)
|
||||||
|
`await` their completion BEFORE falling out of the
|
||||||
|
`try:`/`finally:` that raises `errors` — e.g. gather in a
|
||||||
|
dedicated inner `trio.open_nursery()` block replacing
|
||||||
|
today's `ria_nursery` join point.
|
||||||
|
- delete the membership branch + local reaper nursery from
|
||||||
|
`_trio.py`/`_mp.py` (keep the `soft_kill()` call; the
|
||||||
|
per-child local nursery collapses to just `soft_kill`).
|
||||||
|
- `_trio.py:310` `_children.pop()` etc. unchanged.
|
||||||
|
|
||||||
|
### step B: delete the plumbing
|
||||||
|
|
||||||
|
- `_open_and_supervise...`: drop the inner
|
||||||
|
`ria_nursery` + merge its `except BaseException` classify
|
||||||
|
logic into ONE handler on the (now single) nursery scope;
|
||||||
|
`ActorNursery.__init__` loses the `ria_nursery` param.
|
||||||
|
- `start_actor()` loses the `nursery:` escape-hatch param
|
||||||
|
(the :302-304 TODO).
|
||||||
|
- backends: no more `_cancel_after_result_on_exit` refs.
|
||||||
|
|
||||||
|
### step C: (separate PRs) deprecate + migrate + excise
|
||||||
|
|
||||||
|
- migrate in-repo `.run_in_actor()` usage to
|
||||||
|
`to_actor.run()`: tests 46 hits/9 files (test_cancellation
|
||||||
|
15, test_infected_asyncio 10, test_spawning 8, registrar 3,
|
||||||
|
adv_streaming 4, pubsub 2, rpc 1, runtime 1), examples 28
|
||||||
|
hits/13 files (debugging/* dominate), docs 20 hits/8 rst
|
||||||
|
files. NOTE: many sites also use deprecated
|
||||||
|
`Portal.result()`/`wait_for_result()` — these die with
|
||||||
|
`_expect_result_ctx`, so migration must land FIRST.
|
||||||
|
- add `DeprecationWarning` to `run_in_actor()` (+
|
||||||
|
`_submit_for_result`/`wait_for_result`).
|
||||||
|
- final excision: `run_in_actor()`, `_submit_for_result`,
|
||||||
|
`_expect_result_ctx`, `wait_for_result`/`result`,
|
||||||
|
`exhaust_portal`, `cancel_on_completion`, `NoResult`.
|
||||||
|
|
||||||
|
## Risk register
|
||||||
|
|
||||||
|
1. hard-killed ria child: today the backend-local
|
||||||
|
`nursery.cancel_scope.cancel()` discards a still-parked
|
||||||
|
reaper when the proc dies first; a da_nursery-hosted
|
||||||
|
reaper instead sees the transport break ->
|
||||||
|
`exhaust_portal` returns a `TransportClosed`-ish exc ->
|
||||||
|
NEW entry in `errors` that today gets discarded. Guard:
|
||||||
|
reap-gather block must cancel remaining reapers once all
|
||||||
|
ria procs are dead, or filter transport-death excs for
|
||||||
|
already-`cancel_called` children.
|
||||||
|
2. error-path ordering: inner handler today sets
|
||||||
|
`_join_procs` THEN `an.cancel()`; reapers race the
|
||||||
|
cancel-RPC. Keep that ordering when moving reaper spawn.
|
||||||
|
3. debugger interplay: `maybe_wait_for_debugger()` calls
|
||||||
|
(:654, :730) must stay BEFORE any reap/cancel issuance.
|
||||||
|
4. `errors` double-entry: local body error (:646) + child's
|
||||||
|
relayed exc (via reaper) can both land for the same
|
||||||
|
scenario -> BEG shape changes vs today? (today has the
|
||||||
|
same dual-write sites; keep behavior identical.)
|
||||||
|
5. mp backend parity: mirror every `_trio.py` edit in
|
||||||
|
`_mp.py` (identical block).
|
||||||
|
|
||||||
|
## Step-A first-probe findings (2026-07-02, WIP in tree)
|
||||||
|
|
||||||
|
Step A is IMPLEMENTED (uncommitted):
|
||||||
|
`run_in_actor()` spawns via da_nursery; new
|
||||||
|
`_supervise._reap_ria_portals()` helper; reap awaited after
|
||||||
|
happy-path `_join_procs.set()`; error-path runs reap
|
||||||
|
CONCURRENT with `an.cancel()` in the shielded block;
|
||||||
|
backends stripped of the membership branch + per-child
|
||||||
|
reaper nursery (+ dead imports).
|
||||||
|
|
||||||
|
Probe history (trio backend):
|
||||||
|
- `tests/test_to_actor.py` + `tests/test_spawning.py`:
|
||||||
|
20/20 PASS — incl. all `run_in_actor()` result
|
||||||
|
round-trips + `test_remote_error` (single erroring child,
|
||||||
|
body re-raise -> inner error path).
|
||||||
|
- FIRST attempt ran the error-path reap CONCURRENT with
|
||||||
|
`an.cancel()` (mimicking the old backend-side race):
|
||||||
|
`test_cancellation.py::test_multierror` (2 erroring ria
|
||||||
|
children, body re-raises one) DEADLOCKED. Root cause per
|
||||||
|
the sequencing fix below: reap + cancel must NOT race at
|
||||||
|
this layer (suspected `._children` pop-during-iteration
|
||||||
|
and/or double-cancel RPC wedge; not fully root-caused
|
||||||
|
since the fix removes the race wholesale).
|
||||||
|
- FIX (2nd attempt, current impl): error path SEQUENCES:
|
||||||
|
(1) snapshot ria `(portal, subactor)` pairs (backend
|
||||||
|
`finally`s pop `._children` as procs reap), (2)
|
||||||
|
`await an.cancel()`, (3) bounded reap over the snapshot.
|
||||||
|
Bound was first 3s -> blew the `fail_after` deadline in
|
||||||
|
`test_cancel_while_childs_child_in_sync_sleep` (hard-
|
||||||
|
killed grandchild never relays => reaper parks the full
|
||||||
|
bound). Tightened to 0.5s: anything collectable is
|
||||||
|
already queued in the local ctx (relayed BEFORE the
|
||||||
|
cancel); a parked reaper self-cleans (`trio.Cancelled`
|
||||||
|
results are never stashed).
|
||||||
|
- RESULT: `tests/test_cancellation.py` FULLY GREEN
|
||||||
|
(20 passed, 1 xfailed, 77s); full-suite gate run kicked
|
||||||
|
off same session (see final report/next session).
|
||||||
|
|
||||||
|
Remaining risk: on slow CI a relayed-but-undelivered error
|
||||||
|
racing the 0.5s bound could drop an `errors` entry
|
||||||
|
(BEG-shape flake); if observed, scale the bound via the
|
||||||
|
`cpu_perf_headroom()`-style approach or peek
|
||||||
|
`Portal._final_result_msg`/ctx queue state instead of
|
||||||
|
time-bounding.
|
||||||
|
|
||||||
|
## Step-B outcome (2026-07-02, done in tree)
|
||||||
|
|
||||||
|
Step A landed as `5cd190c5` (code) + `99310269` (docs).
|
||||||
|
Step B implemented on top (uncommitted):
|
||||||
|
|
||||||
|
- `._ria_nursery` is GONE — the inner
|
||||||
|
`async with (collapse_eg(), trio.open_nursery() as
|
||||||
|
ria_nursery)` layer in
|
||||||
|
`_open_and_supervise_one_cancels_all_nursery` is deleted;
|
||||||
|
`da_nursery` is now the single nursery for ALL subactors.
|
||||||
|
- `ActorNursery.__init__` drops the `ria_nursery` param +
|
||||||
|
the `self._ria_nursery` attr; `start_actor()` drops its
|
||||||
|
`nursery=` escape-hatch param (uses `self._da_nursery`
|
||||||
|
directly).
|
||||||
|
- `._cancel_after_result_on_exit` STAYS — it's the
|
||||||
|
ria-child discriminator for `_reap_ria_portals()`.
|
||||||
|
|
||||||
|
Deliberately NOT done (deferred to its own higher-risk PR,
|
||||||
|
flagged with a TODO at the outer `except`): merging the two
|
||||||
|
error handlers into one. Rationale — collapsing the empty
|
||||||
|
nursery is provably behavior-preserving (a zero-task
|
||||||
|
`trio.open_nursery()` only adds a checkpoint), whereas the
|
||||||
|
inner `except BaseException` (swallow-into-`errors`) and
|
||||||
|
outer `except (...)` (re-raise, safety-net for the inner
|
||||||
|
handler's own non-shielded awaits) have DIFFERENT
|
||||||
|
semantics; merging changes error/cancel propagation and
|
||||||
|
wants isolated review + its own gate. Both handlers are
|
||||||
|
kept, now nested directly under the single nursery.
|
||||||
|
|
||||||
|
Why the collapse is safe: post-step-A NOTHING spawns into
|
||||||
|
`ria_nursery` (its only reader, `run_in_actor`'s
|
||||||
|
`nursery=self._ria_nursery`, was removed in A; the stored
|
||||||
|
attr was never read again). So the layer was pure dead
|
||||||
|
weight.
|
||||||
|
|
||||||
|
Gate (trio backend, all 0-failure):
|
||||||
|
- targeted set (`test_cancellation test_spawning test_local
|
||||||
|
test_rpc test_to_actor`) = 49 passed, 1 xfailed.
|
||||||
|
- tail set (`test_reg_err_types remote_exc_relay
|
||||||
|
resource_cache ringbuf root_infect_asyncio root_runtime
|
||||||
|
runtime shm task_broadcasting trioisms trionics/`) = 63
|
||||||
|
passed, 1 skipped, 5 xfailed.
|
||||||
|
- full-suite head ~73% (subdirs + `test_2way`..`test_pubsub`)
|
||||||
|
= 303 passed before the known-flaky `test_dynamic_pub_sub`
|
||||||
|
TooSlowError stall (pre-existing; same hang in the step-A
|
||||||
|
full run). Suite ran slow this session (~13min vs 555s
|
||||||
|
cold, likely thermal from back-to-back runs), never
|
||||||
|
completing within an 800s bound — but split across the
|
||||||
|
above three runs EVERY module passed under step B.
|
||||||
|
|
||||||
|
## Step-B2 outcome (2026-07-02, done in tree)
|
||||||
|
|
||||||
|
Step B committed as `9201a2ed` (code) + `d2e812fb` (docs), then
|
||||||
|
branched to `drop_ria_nursery`. Step B2 (the deferred
|
||||||
|
handler-merge) implemented on top (uncommitted):
|
||||||
|
|
||||||
|
- the two nested handlers in
|
||||||
|
`_open_and_supervise_one_cancels_all_nursery` collapse to
|
||||||
|
ONE `except BaseException as _scope_err` + the existing
|
||||||
|
`finally`. The `outer_err`/`inner_err` locals go away.
|
||||||
|
|
||||||
|
Why it's safe (trace, not hope): the OLD inner handler records
|
||||||
|
`errors[actor.aid.uid]` as its FIRST statement (before any
|
||||||
|
await). So whenever an error path runs, `errors` is non-empty.
|
||||||
|
The OLD outer handler was only reachable via leakage from the
|
||||||
|
inner handler (it catches `BaseException`, so nothing from the
|
||||||
|
`yield` scope bypasses it) — and by then `errors` is already
|
||||||
|
populated, so the `finally`'s `raise` from `errors` ALWAYS
|
||||||
|
superseded the outer handler's own `raise`. i.e. the outer
|
||||||
|
`raise` was dead. The outer handler's other effects
|
||||||
|
(`_scope_error`, a 2nd debugger-wait, child-cancel) are
|
||||||
|
redundant with the merged handler + `finally`. So one handler
|
||||||
|
+ `finally` is observably equivalent.
|
||||||
|
|
||||||
|
Residual nuance (accepted): in the rare "`trio.Cancelled`
|
||||||
|
delivered during the non-shielded `maybe_wait_for_debugger`"
|
||||||
|
path, the merged form may leave `_cancel_called` False (cancel
|
||||||
|
happens after the wait), so `open_nursery`'s tb-hiding guard
|
||||||
|
(`not cancel_called and _scope_error`) can show a tb it
|
||||||
|
previously hid. More informative, not less; no test asserts on
|
||||||
|
it.
|
||||||
|
|
||||||
|
Gate (box ran ~2.7x slow this session, load-induced
|
||||||
|
`TooSlowError` flakiness on timing tests — NOT code; see
|
||||||
|
[[env_cpu_throttle_masquerades_as_regression]]):
|
||||||
|
- baseline (pre-B2 tip `9201a2ed`) full suite
|
||||||
|
(`-k 'not dynamic_pub_sub'`) = 300 passed + 1
|
||||||
|
`test_ext_types_over_ipc` `TooSlowError` that passes 6/6 in
|
||||||
|
isolation (4.89s).
|
||||||
|
- B2 error/cancel gate (`test_cancellation remote_exc_relay
|
||||||
|
inter_peer_cancellation advanced_faults oob_cancellation
|
||||||
|
to_actor spawning local rpc`) = 71 passed, 1 xfailed
|
||||||
|
(125s).
|
||||||
|
- B2 full-suite run: see `b2_full.log` (result appended on
|
||||||
|
completion). RECOMMEND a clean full-suite run on a
|
||||||
|
normal-speed box before this merges.
|
||||||
|
|
||||||
|
## Regression + fix: ria-reap hang (2026-07-02)
|
||||||
|
|
||||||
|
Human hit a full-suite hang on
|
||||||
|
`test_infected_asyncio.py::test_tractor_cancels_aio`. Bisected:
|
||||||
|
passes at pre-ria `a34aaf98` (0.59s), hangs at B2 `e617b498`
|
||||||
|
(90s+). Root-caused to the STEP-A reaper hoist (`5cd190c5`),
|
||||||
|
NOT B2 (`_reap_ria_portals` is byte-identical A->B2).
|
||||||
|
|
||||||
|
Bug: the test does `run_in_actor(asyncio_actor)` then a USER
|
||||||
|
`portal.cancel_actor()` and exits the block cleanly -> the
|
||||||
|
happy path's `await _reap_ria_portals()`, which waits UNBOUNDED
|
||||||
|
on `cancel_on_completion -> wait_for_result()`. The child was
|
||||||
|
cancelled out-of-band so no final result is relayed -> parked
|
||||||
|
forever. The OLD spawn-backend reaper was raced against
|
||||||
|
`soft_kill()` (per-child nursery `cancel_scope.cancel()` on
|
||||||
|
subproc death); the hoist dropped that race.
|
||||||
|
|
||||||
|
Fix: `_reap_ria_portals()` runs each `cancel_on_completion()`
|
||||||
|
in a local nursery alongside a `proc.poll()` death-watch that
|
||||||
|
cancels the parked reaper once the subproc exits — restoring
|
||||||
|
the old race, backend-agnostic (guarded by
|
||||||
|
`hasattr(proc, 'poll')` for a future `subint` handle).
|
||||||
|
|
||||||
|
Why POLL (`proc.poll()`) not the event-driven `wait_func`:
|
||||||
|
the mp waiter (`_spawn.proc_waiter`) does
|
||||||
|
`wait_readable(proc.sentinel)`, and `soft_kill()` is ALREADY
|
||||||
|
awaiting that same fd concurrently in the daemon nursery — a
|
||||||
|
2nd `wait_readable` on one fd raises `trio.BusyResourceError`.
|
||||||
|
(`trio.Process.wait()` IS multi-waiter-safe, but mp has no
|
||||||
|
async equivalent.) `proc.poll()` — the same liveness check
|
||||||
|
`soft_kill` itself falls back to — is the conflict-free common
|
||||||
|
denominator. Verified: poll-fix passes on BOTH trio and
|
||||||
|
mp_spawn.
|
||||||
|
|
||||||
|
Also added a per-test anti-hang guard: wrapped
|
||||||
|
`test_tractor_cancels_aio`'s `main()` in
|
||||||
|
`with trio.fail_after(9 * cpu_perf_headroom())` — the blessed
|
||||||
|
pattern (`pytest-timeout`'s global cap is intentionally off;
|
||||||
|
breaks trio under fork backends, see `pyproject` NOTE). So a
|
||||||
|
future recurrence FAILS FAST instead of hanging the suite.
|
||||||
|
(Several other tests in the file are still guardless —
|
||||||
|
`test_aio_simple_error`, `test_trio_error_cancels_intertask_chan`,
|
||||||
|
`test_aio_errors_and_channel_propagates_and_closes` — candidate
|
||||||
|
follow-up sweep.)
|
||||||
|
|
||||||
|
Lesson: the B2 focused gate OMITTED `test_infected_asyncio`
|
||||||
|
(and the full runs were clipped/slow), so the step-A hang
|
||||||
|
slipped through. Any future ria-touching change MUST gate
|
||||||
|
`test_infected_asyncio` explicitly.
|
||||||
|
|
||||||
|
Gate: `test_tractor_cancels_aio` green (trio 1.53s, mp 3.98s);
|
||||||
|
fix gate (`test_infected_asyncio test_cancellation test_to_actor
|
||||||
|
test_spawning`) = 74 passed, 3 xfailed, 0 failures.
|
||||||
|
|
||||||
|
## PAUSED (2026-07-02): re-assess the reaper's SCOPE
|
||||||
|
|
||||||
|
User's insight (compelling — likely the real root cause of
|
||||||
|
the hang, not just the missing proc-death race):
|
||||||
|
|
||||||
|
> the "hoisting" of 5cd190c5 was just not really done right
|
||||||
|
> — the hoist should have been into the `to_actor` scope,
|
||||||
|
> not `_supervise`.
|
||||||
|
|
||||||
|
The argument: `.run_in_actor()`'s result-waiting/reaping got
|
||||||
|
hoisted into `_supervise._reap_ria_portals` (nursery-machinery
|
||||||
|
scope), which has NO natural cancel-scope to bound a parked
|
||||||
|
`wait_for_result()` — hence the awkward proc-death race +
|
||||||
|
the poll-vs-`proc_waiter` dilemma. If the result-wait instead
|
||||||
|
lived in the `to_actor` one-shot scope
|
||||||
|
(`to_actor._invoke_in_subactor()`), it would sit right next to
|
||||||
|
the caller's `an` + a local `trio` task-nursery + cancel-scope
|
||||||
|
(the `trio.to_thread`-style model #477 actually wants) — so
|
||||||
|
bounding/cancelling the wait is trivial and the hang
|
||||||
|
dissolves from correct scoping rather than a bolt-on race.
|
||||||
|
|
||||||
|
Follow-on to re-evaluate on resume:
|
||||||
|
- should `_reap_ria_portals` exist AT ALL, or should
|
||||||
|
result-waiting move entirely into
|
||||||
|
`to_actor._invoke_in_subactor()`?
|
||||||
|
- reimplement legacy `run_in_actor()` on top of
|
||||||
|
`to_actor.run()` so `_reap_ria_portals` +
|
||||||
|
`_cancel_after_result_on_exit` can be DROPPED from
|
||||||
|
`_supervise` entirely (the true #477 simplification)?
|
||||||
|
- the poll-vs-event decision is MOOT under this re-scoping.
|
||||||
|
|
||||||
|
State at pause: `test_infected_asyncio` anti-hang guard
|
||||||
|
COMMITTED (`d1fb4a1a`, intentionally red w/o the fix — the
|
||||||
|
user's failing-test-first convention). The poll-based reap
|
||||||
|
fix in `_supervise.py` is UNCOMMITTED and likely SUPERSEDED
|
||||||
|
by the re-scoping — do NOT land it as-is.
|
||||||
|
|
||||||
|
## RESOLVED (2026-07-06): migrate everything, remove the API
|
||||||
|
|
||||||
|
The PAUSED re-assessment concluded decisively: rather than
|
||||||
|
re-scope `_reap_ria_portals` (or bolt any hack onto it), the
|
||||||
|
`run_in_actor()` API itself was REMOVED — its non-blocking
|
||||||
|
"result at teardown" semantic predates streaming and confused
|
||||||
|
more than it served. Every in-repo caller was migrated
|
||||||
|
per-file/-group (each its own commit, each gated):
|
||||||
|
|
||||||
|
- tests: `test_infected_asyncio` `test_runtime` `test_rpc`
|
||||||
|
`test_spawning` `test_pubsub` `test_registrar`
|
||||||
|
`test_cancellation` (3 groups) `test_advanced_streaming`.
|
||||||
|
- examples: 4 non-debugging + all 8 `debugging/` REPL scripts
|
||||||
|
(debugger suite byte-identical green, 28p/6s).
|
||||||
|
- docs: 8 rst pages + the `experimental/_pubsub` docstring.
|
||||||
|
|
||||||
|
Migration patterns (the `run_in_actor` shape -> successor):
|
||||||
|
|
||||||
|
- blocking result -> `to_actor.run(fn, an=an, ...)`
|
||||||
|
- fire-&-forget/forever -> bg `to_actor.run()` task in a local
|
||||||
|
`trio` task-nursery (or `start_actor`
|
||||||
|
+ bg `Portal.run()` when a portal
|
||||||
|
handle is needed)
|
||||||
|
- concurrent fan-out -> N bg `to_actor.run()` tasks / or
|
||||||
|
`gather_contexts([p.open_context(..)])`
|
||||||
|
- reap-all-error-collect -> the "collect don't cancel" pattern:
|
||||||
|
each one-shot catches + stashes its
|
||||||
|
`RemoteActorError`, group raised
|
||||||
|
after the task-nursery joins (see
|
||||||
|
`examples/debugging/multi_subactors.py`)
|
||||||
|
- mutual-rendezvous -> peers must OUTLIVE both dialogs:
|
||||||
|
`start_actor()` daemons + concurrent
|
||||||
|
`Portal.run()`s + explicit
|
||||||
|
`an.cancel()` (eager one-shot reap
|
||||||
|
races the slower peer's dial of the
|
||||||
|
winner's dead sockaddr; found via
|
||||||
|
`test_trynamic_trio` flake).
|
||||||
|
|
||||||
|
Semantic deltas (tests loosened accordingly):
|
||||||
|
|
||||||
|
- teardown-reap-all BEG-of-N is GONE: local task-nurseries are
|
||||||
|
cancel-on-first, raced siblings' `Cancelled`s are absorbed,
|
||||||
|
and the runtime's `collapse_eg()` unwraps every single-member
|
||||||
|
group at each actor boundary — a fully-raced nested tree
|
||||||
|
relays a bare (annotated) `RemoteActorError` chain.
|
||||||
|
- `test_multierror_fast_nursery`'s obsolete BEG-of-25 assertion
|
||||||
|
deleted; `test_concurrent_start_error_reaps_all` retains its
|
||||||
|
high-fan-out startup/cancel/reap stress under caller-scoped
|
||||||
|
semantics.
|
||||||
|
- `test_nested_multierrors` re-purposed separately as deep-tree
|
||||||
|
cancel-cascade stress w/ a race-tolerant shape walk.
|
||||||
|
|
||||||
|
Final excision (after zero callers remained): `run_in_actor()`,
|
||||||
|
`._cancel_after_result_on_exit`, `_reap_ria_portals()`,
|
||||||
|
`Portal._submit_for_result/._expect_result_ctx/
|
||||||
|
.wait_for_result()/.result()`, `exhaust_portal()`,
|
||||||
|
`cancel_on_completion()`, `NoResult` — net -402 lines. The
|
||||||
|
reap-hang class (unbounded `wait_for_result` in machinery
|
||||||
|
scope) dissolves structurally: the only result-wait left lives
|
||||||
|
in the caller's task inside its own cancel-scope; the
|
||||||
|
`d1fb4a1a` anti-hang guard test passes by construction. The
|
||||||
|
poll-vs-`proc_waiter` debate is moot as predicted.
|
||||||
|
|
||||||
|
## Follow-up sketch: `to_actor.open_one_shot()` (run-async parity)
|
||||||
|
|
||||||
|
If deferred-result parity is ever wanted, the design that needs
|
||||||
|
NO runtime coupling, NO returned `Portal` and NO cancel-relay
|
||||||
|
`trio.Event` machinery:
|
||||||
|
|
||||||
|
async with to_actor.open_one_shot(
|
||||||
|
fn, an=an, **kws,
|
||||||
|
) as one_shot:
|
||||||
|
... # concurrent caller work
|
||||||
|
val = await one_shot.wait() # optional; errors always
|
||||||
|
# propagate at scope exit
|
||||||
|
|
||||||
|
an `@acm` that opens a private task-nursery, `start_soon`s ONE
|
||||||
|
task running the existing blocking `run()` and stashes the
|
||||||
|
value in a slot + sets a done-`trio.Event` (a memo, not a
|
||||||
|
cancel relay). Cancellation = plain scope-cancel of the acm's
|
||||||
|
nursery (the parked `Portal.run()` unwinds via `Cancelled`, the
|
||||||
|
shielded `cancel_actor()` reap still runs); a child error
|
||||||
|
raises into the acm scope so an un-`wait()`ed one-shot can
|
||||||
|
never silently drop its error. i.e. the old reaper's job is
|
||||||
|
done by scoping, not machinery. ~40 lines, all in
|
||||||
|
`to_actor/_api.py`, zero `_supervise` involvement.
|
||||||
|
|
||||||
|
## Verification gate
|
||||||
|
|
||||||
|
- per-migration-commit module gates on `trio` (+ `mp_spawn`
|
||||||
|
spot-gates incl. `test_infected_asyncio` per the B2 lesson);
|
||||||
|
`tests/devx/test_debugger.py` for the REPL flows.
|
||||||
|
- full suite on `trio` + `mp_spawn` at branch tip + CI matrix
|
||||||
|
via draft PR #484.
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
---
|
||||||
|
model: claude-fable-5
|
||||||
|
service: claude
|
||||||
|
session: f6c84722-471a-4458-9a80-e453fea9029f
|
||||||
|
timestamp: 2026-07-02T15:42:55Z
|
||||||
|
git_ref: 65bf9df5
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260702T154255Z_65bf9df5_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Driver prompt file `ai/prompt-io/prompts/issue_477.md`:
|
||||||
|
|
||||||
|
> attempt to resolve
|
||||||
|
> https://github.com/goodboy/tractor/issues/477
|
||||||
|
> do it with /open-wkt.
|
||||||
|
|
||||||
|
(plus a hard stop-for-human-review deadline of 12:50PM
|
||||||
|
EST the same day)
|
||||||
|
|
||||||
|
Issue #477 asks to factor `ActorNursery.run_in_actor()`
|
||||||
|
(and possibly `Portal.run()`) out of the nursery
|
||||||
|
internals into a new `tractor.to_actor` wrapper
|
||||||
|
subpackage of "higher level one shot" single-remote-task
|
||||||
|
APIs, adopting the `trio.to_thread`/`anyio.to_process`
|
||||||
|
parlance, so that error collection/propagation moves up
|
||||||
|
into the caller's local `trio` scope and the nursery's
|
||||||
|
spawn machinery can eventually drop the
|
||||||
|
`._ria_nursery` coupling.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
First-cut `tractor.to_actor` subpkg delivering the
|
||||||
|
one-shot API composed purely from the existing
|
||||||
|
daemon-spawn + portal primitives (`start_actor()` +
|
||||||
|
`Portal.run()` + `Portal.cancel_actor()`), leaving the
|
||||||
|
legacy `.run_in_actor()` machinery untouched (formal
|
||||||
|
deprecation deferred until in-repo usage migrates):
|
||||||
|
|
||||||
|
- `to_actor.run(fn, **fn_kwargs) -> Any`: spawn a
|
||||||
|
subactor, schedule `fn` as its lone remote task, wait
|
||||||
|
on and return its result, ALWAYS reaping the subactor
|
||||||
|
(shield-safe `finally`). Remote errors raise in the
|
||||||
|
caller's task as boxed `RemoteActorError`s.
|
||||||
|
- placement variants: `portal=` reuses a running actor
|
||||||
|
(no spawn/reap), `an=` spawns from a caller-managed
|
||||||
|
actor-nursery, neither opens a call-scoped private
|
||||||
|
`open_nursery()` (implicitly booting the runtime,
|
||||||
|
configurable via `runtime_kwargs`).
|
||||||
|
- fail-fast validation before any spawn: non-streaming
|
||||||
|
async fn required; `portal=`/`an=` mutually
|
||||||
|
exclusive; `runtime_kwargs` rejected alongside any
|
||||||
|
placement opt.
|
||||||
|
- `run_in_actor()` TODO/docstring now cross-reference
|
||||||
|
the successor API.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/to_actor/__init__.py` — new subpkg,
|
||||||
|
re-exports `run`
|
||||||
|
- `tractor/to_actor/_api.py` — `run()` +
|
||||||
|
`_invoke_in_subactor()` + `_validate_one_shot_fn()`
|
||||||
|
- `tractor/__init__.py` — top-level `to_actor`
|
||||||
|
re-export
|
||||||
|
- `tractor/runtime/_supervise.py` — comment/docstring
|
||||||
|
pointers from `run_in_actor()` to the successor
|
||||||
|
- `tests/test_to_actor.py` — 11-test suite covering
|
||||||
|
all placement variants, error relay, the concurrent
|
||||||
|
worker-pool-ish pattern and arg validation
|
||||||
|
- `examples/parallelism/to_actor_one_shots.py` —
|
||||||
|
runnable demo (auto-collected by
|
||||||
|
`test_docs_examples.py`)
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
None yet — pending human review (work paused before the
|
||||||
|
12:50PM EST deadline per the driver prompt).
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
---
|
||||||
|
model: claude-fable-5
|
||||||
|
service: claude
|
||||||
|
timestamp: 2026-07-02T15:42:55Z
|
||||||
|
git_ref: 65bf9df5
|
||||||
|
diff_cmd: git diff main..wkt/to_actor_subpkg
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw AI output (diff-ref mode)
|
||||||
|
|
||||||
|
All generated code is committed on the
|
||||||
|
`wkt/to_actor_subpkg` branch; per diff-ref mode each
|
||||||
|
file's verbatim content is reachable via the pointers
|
||||||
|
below rather than duplicated here.
|
||||||
|
|
||||||
|
## Generated files
|
||||||
|
|
||||||
|
> `git diff main..wkt/to_actor_subpkg -- tractor/to_actor/__init__.py`
|
||||||
|
|
||||||
|
New subpackage init: module docstring establishing the
|
||||||
|
`trio.to_thread`/`anyio.to_process` "run it over there"
|
||||||
|
parlance for actors, plus the single public re-export
|
||||||
|
`run as run` from `._api`.
|
||||||
|
|
||||||
|
> `git diff main..wkt/to_actor_subpkg -- tractor/to_actor/_api.py`
|
||||||
|
|
||||||
|
The one-shot invocation impl, composed entirely from the
|
||||||
|
lower level daemon-spawn + portal primitives as
|
||||||
|
prescribed by issue #477:
|
||||||
|
|
||||||
|
- `_validate_one_shot_fn()`: the `Portal.run()`
|
||||||
|
non-streaming-async-fn constraint checked up-front,
|
||||||
|
before any subactor is spawned.
|
||||||
|
- `_invoke_in_subactor()`: `an.start_actor()` ->
|
||||||
|
`Portal.run()` -> always-reap via
|
||||||
|
`Portal.cancel_actor()` in a `finally` (the cancel
|
||||||
|
req's bounded wait is internally shielded so the reap
|
||||||
|
also runs under caller-scope cancellation).
|
||||||
|
- `run()`: the public API. Placement options:
|
||||||
|
`portal=` (reuse a running actor, no spawn/reap),
|
||||||
|
`an=` (spawn from a caller-managed nursery), or
|
||||||
|
neither (private `open_nursery()` scoped to the call,
|
||||||
|
implicitly booting the runtime when needed, tunable
|
||||||
|
via pass-through `runtime_kwargs`). Spawn opts mirror
|
||||||
|
`ActorNursery.start_actor()`; `**fn_kwargs` are
|
||||||
|
relayed to the remote task. Errors raise in the
|
||||||
|
caller's task as boxed `RemoteActorError`s.
|
||||||
|
`runtime_kwargs` alongside any placement opt is a
|
||||||
|
hard `ValueError`, never silently ignored.
|
||||||
|
|
||||||
|
> `git diff main..wkt/to_actor_subpkg -- tractor/__init__.py`
|
||||||
|
|
||||||
|
Top-level `from . import to_actor as to_actor`
|
||||||
|
re-export.
|
||||||
|
|
||||||
|
> `git diff main..wkt/to_actor_subpkg -- tractor/runtime/_supervise.py`
|
||||||
|
|
||||||
|
Comment/docstring-only: the `run_in_actor()` deprecation
|
||||||
|
TODO now points at the implemented `.to_actor.run()`
|
||||||
|
successor (checkbox ticked) and the method docstring
|
||||||
|
gains a NOTE steering users to the new API; remaining
|
||||||
|
TODO items are the `DeprecationWarning` emission +
|
||||||
|
in-repo usage migration.
|
||||||
|
|
||||||
|
> `git diff main..wkt/to_actor_subpkg -- tests/test_to_actor.py`
|
||||||
|
|
||||||
|
11-test suite: private-nursery one-shot, implicit
|
||||||
|
runtime boot via `runtime_kwargs`, remote-error relay to
|
||||||
|
the caller's task (bare + caller-managed nursery),
|
||||||
|
caller-nursery spawn, portal reuse w/o implicit reap,
|
||||||
|
the concurrent worker-pool-ish pattern (local `trio`
|
||||||
|
nursery x shared `an`), and the four validation
|
||||||
|
rejections (sync fn, async-gen fn, `portal`+`an`
|
||||||
|
combo, `runtime_kwargs`+placement combo).
|
||||||
|
|
||||||
|
> `git diff main..wkt/to_actor_subpkg -- examples/parallelism/to_actor_one_shots.py`
|
||||||
|
|
||||||
|
Runnable example (auto-collected by
|
||||||
|
`test_docs_examples.py`): the fully-implicit one-shot
|
||||||
|
plus the concurrent worker-pool-ish prime-check pattern
|
||||||
|
against a shared caller-managed actor-nursery.
|
||||||
|
|
||||||
|
## Test runs (verbatim)
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_to_actor.py .......... [100%]
|
||||||
|
============= 10 passed in 4.29s =============
|
||||||
|
```
|
||||||
|
|
||||||
|
Regression subset for touched modules
|
||||||
|
(`test_local.py test_rpc.py test_spawning.py
|
||||||
|
test_cancellation.py`):
|
||||||
|
|
||||||
|
```
|
||||||
|
38 passed, 1 xfailed, 24 warnings in 80.71s (0:01:20)
|
||||||
|
```
|
||||||
|
|
||||||
|
(warnings are pre-existing stdlib `os.fork()`
|
||||||
|
DeprecationWarnings from the mp spawn backends, not
|
||||||
|
introduced by this change)
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
---
|
||||||
|
model: claude-fable-5
|
||||||
|
service: claude
|
||||||
|
session: b8ec2ffe-1e47-4db5-8bc3-5bebc5feaaf6
|
||||||
|
timestamp: 2026-07-02T15:50:06Z
|
||||||
|
git_ref: 65bf9df5
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260702T155006Z_65bf9df5_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
From `ai/prompt-io/prompts/issue_473.md`:
|
||||||
|
|
||||||
|
> NOTE: you MUST pause this work at 12:50PM EST (BEFORE
|
||||||
|
> your weekly limit reset) for review by a human!
|
||||||
|
>
|
||||||
|
> resolve GH #473: https://github.com/goodboy/tractor/issues/474
|
||||||
|
> do it with /open-wkt.
|
||||||
|
|
||||||
|
(The prompt's issue-number/URL mismatch was resolved in
|
||||||
|
favor of #473 — file name, session name and prompt text
|
||||||
|
all say #473; the `/474` URL is a typo.)
|
||||||
|
|
||||||
|
GH #473: "Root-cause UDS-on-macOS —
|
||||||
|
`uds_transport_actor_tree` example fails on CI".
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Worked all four #473 next-step checkboxes in wkt
|
||||||
|
`uds_macos_473`:
|
||||||
|
|
||||||
|
1. **stderr surfacing**: `test_docs_examples.py` now
|
||||||
|
raises with the full subproc stderr (+stdout) on ANY
|
||||||
|
non-zero exit. The old last-line-'Error' check could
|
||||||
|
never fire for `tractor` crashes since root-actor
|
||||||
|
stderr always ends with the strict-EG collapse note
|
||||||
|
`( ^^^ this exc was collapsed from a group ^^^ )` —
|
||||||
|
proven against the real PR #460 macOS CI log (bare
|
||||||
|
`assert 1 == 0`, no traceback).
|
||||||
|
2. **root-cause (linux-provable layer)**: macOS-only
|
||||||
|
addr corruption in
|
||||||
|
`MsgpackUDSStream.get_stream_addrs()` — no
|
||||||
|
`SO_PASSCRED`/autobind on darwin means the accept
|
||||||
|
side's `getpeername()` is `''`, and the
|
||||||
|
`(str(), str())` arm took `peername` unconditionally →
|
||||||
|
`Path('')` garbage addrs on every accepted conn.
|
||||||
|
Proven + fixed via linux no-autobind simulation.
|
||||||
|
Possibly not the final macOS crasher (non-fatal on
|
||||||
|
linux-sim); the diagnostic patch guarantees the next
|
||||||
|
macOS CI run shows any remaining layer.
|
||||||
|
3. **CI matrix**: removed the `macos-latest`+`uds`
|
||||||
|
exclude.
|
||||||
|
4. **un-skip**: dropped the macOS+CI skip of the example.
|
||||||
|
|
||||||
|
Also: `start_listener()` bindspace mkdir hardened
|
||||||
|
(`parents=True, exist_ok=True`), example docstring
|
||||||
|
peer-pid mechanism corrected for macOS.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tests/test_docs_examples.py` — surface full stderr on
|
||||||
|
non-zero exit; remove macOS skip of the UDS example
|
||||||
|
- `tractor/ipc/_uds.py` — fix no-autobind
|
||||||
|
`get_stream_addrs()` arm; harden bindspace mkdir;
|
||||||
|
document autobind semantics
|
||||||
|
- `.github/workflows/ci.yml` — add macOS UDS leg (remove
|
||||||
|
matrix exclude)
|
||||||
|
- `examples/uds_transport_actor_tree.py` — platform-
|
||||||
|
correct peer-pid docs
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
None yet — pending human review (hard-stop review
|
||||||
|
checkpoint at 12:50PM EST per prompt); commit staging and
|
||||||
|
any edits are the human's.
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
---
|
||||||
|
model: claude-fable-5
|
||||||
|
service: claude
|
||||||
|
timestamp: 2026-07-02T15:50:06Z
|
||||||
|
git_ref: 65bf9df5
|
||||||
|
diff_cmd: git diff main..wkt/uds_macos_473
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output — GH #473 UDS-on-macOS root-cause session
|
||||||
|
|
||||||
|
NOTE: code output is diff-referenced (not copied) per
|
||||||
|
prompt-io diff-ref mode; all generated code is on branch
|
||||||
|
`wkt/uds_macos_473` relative to `main` (65bf9df5).
|
||||||
|
|
||||||
|
## Diagnostic narrative (verbatim)
|
||||||
|
|
||||||
|
Root-cause work proceeded by linux-side simulation since no
|
||||||
|
macOS host is available:
|
||||||
|
|
||||||
|
1. Pulled the actual macOS CI failure log from PR #460 run
|
||||||
|
28343878933 (job 84115107992): the failure is a bare
|
||||||
|
`assert 1 == 0` on `proc.returncode` with NO stderr
|
||||||
|
surfaced — the harness only re-raised when the LAST
|
||||||
|
stderr line contained 'Error'.
|
||||||
|
|
||||||
|
2. Reproduced the stderr-swallow mechanism on linux: any
|
||||||
|
`tractor` root-actor crash ends stderr with the
|
||||||
|
strict-EG collapse note
|
||||||
|
`( ^^^ this exc was collapsed from a group ^^^ )` which
|
||||||
|
never matches 'Error' — so EVERY possible crash was
|
||||||
|
swallowed. (Verified by sabotaging the runtime dir via
|
||||||
|
an over-long `XDG_RUNTIME_DIR` → `OSError: AF_UNIX path
|
||||||
|
too long` → rc=1 + swallowed.)
|
||||||
|
|
||||||
|
3. Found + proved a macOS-only addr-corruption bug in
|
||||||
|
`MsgpackUDSStream.get_stream_addrs()`: the
|
||||||
|
`(str(), str())` match-arm unconditionally took
|
||||||
|
`peername`, but on no-autobind platforms (macOS lacks
|
||||||
|
linux's `SO_PASSCRED`-triggered autobind) the accept
|
||||||
|
side's `getpeername()` is `''` → `Path('')` garbage
|
||||||
|
laddr/raddr on EVERY accepted UDS conn. Simulated on
|
||||||
|
linux by nulling `SO_PASSCRED` (no autobind → same `''`
|
||||||
|
shape): pre-fix the example printed
|
||||||
|
`listener sock file: .`; post-fix it prints the real
|
||||||
|
registry sockpath. Non-fatal on linux-sim (rc=0), so
|
||||||
|
possibly not the final macOS crasher — the diagnostic
|
||||||
|
patch guarantees the next macOS CI run reveals any
|
||||||
|
remaining layer.
|
||||||
|
|
||||||
|
4. Falsified the missing-parent-dir theory:
|
||||||
|
`get_rt_dir()` already `mkdir(parents=True,
|
||||||
|
exist_ok=True)`s at import (and macOS TCP CI passes),
|
||||||
|
so `~/Library/Caches/TemporaryItems` absence cannot be
|
||||||
|
the crasher. Hardened `start_listener()`'s bindspace
|
||||||
|
mkdir anyway (custom `filedir` case + racing actors).
|
||||||
|
|
||||||
|
## Generated changes (diff pointers)
|
||||||
|
|
||||||
|
> `git diff main..wkt/uds_macos_473 -- tests/test_docs_examples.py`
|
||||||
|
|
||||||
|
- always raise with FULL subproc stderr (+stdout) on any
|
||||||
|
non-zero example exit; keep legacy last-line 'Error'
|
||||||
|
check for zero-rc cases; drop the macOS+CI skip of
|
||||||
|
`uds_transport_actor_tree.py` (GH #473 next-step).
|
||||||
|
|
||||||
|
> `git diff main..wkt/uds_macos_473 -- tractor/ipc/_uds.py`
|
||||||
|
|
||||||
|
- `get_stream_addrs()`: document the autobind semantics
|
||||||
|
(bytes = linux abstract-ns autobind artifact), add
|
||||||
|
no-autobind `(str, str)` arm picking the non-empty name
|
||||||
|
(`peername` connect-side, `sockname` accept-side) with
|
||||||
|
an empty-pair `ValueError` guard.
|
||||||
|
- `start_listener()`: `bs.mkdir(parents=True,
|
||||||
|
exist_ok=True)`.
|
||||||
|
|
||||||
|
> `git diff main..wkt/uds_macos_473 -- .github/workflows/ci.yml`
|
||||||
|
|
||||||
|
- remove the `macos-latest`+`uds` matrix exclude so
|
||||||
|
UDS-on-macOS is exercised by CI (GH #473 next-step).
|
||||||
|
|
||||||
|
> `git diff main..wkt/uds_macos_473 -- examples/uds_transport_actor_tree.py`
|
||||||
|
|
||||||
|
- docs nit: peer-pid mechanism is `SO_PEERCRED` on linux,
|
||||||
|
`LOCAL_PEERPID` on macOS.
|
||||||
|
|
||||||
|
## Verification (verbatim summary)
|
||||||
|
|
||||||
|
- macOS-shape sim (no autobind) + fix: example rc=0 with
|
||||||
|
correct listener sockpath.
|
||||||
|
- native linux post-fix: example rc=0, autobind arms
|
||||||
|
unchanged.
|
||||||
|
- sabotage smoke-test: diagnostic patch surfaces the full
|
||||||
|
EG traceback incl. collapse-note line.
|
||||||
|
- `pytest tests/ipc/ tests/test_2way.py tests/discovery/
|
||||||
|
--tpt-proto uds`: 61 passed, 1 `TooSlowError` flake
|
||||||
|
(test_simple_rpc) that passes solo in 2.2s — attributed
|
||||||
|
to a concurrent full-suite run (other session) loading
|
||||||
|
the host.
|
||||||
|
- full `tests/test_docs_examples.py` run pending at
|
||||||
|
raw-file write time (queued behind the concurrent
|
||||||
|
session's suite).
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
---
|
||||||
|
model: claude-fable-5
|
||||||
|
service: claude
|
||||||
|
session: b6b42e23-0454-4d9b-be1c-0a89d65aed1b
|
||||||
|
timestamp: 2026-07-02T15:56:26Z
|
||||||
|
git_ref: 65bf9df5
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260702T155626Z_65bf9df5_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
From `ai/prompt-io/prompts/issue_470.md`:
|
||||||
|
|
||||||
|
> attempt to resolve
|
||||||
|
> https://github.com/goodboy/tractor/issues/470
|
||||||
|
> do it with /open-wkt, ensure you /run-tests on all
|
||||||
|
> changes
|
||||||
|
|
||||||
|
(plus a hard pause-for-human-review deadline of
|
||||||
|
12:50PM EST.)
|
||||||
|
|
||||||
|
Issue #470 asks to trim the ~0.42s `import tractor`
|
||||||
|
cost — which dominates per-actor spawn latency on the
|
||||||
|
`trio` backend — by lazy-importing heavy/optional deps
|
||||||
|
(`pdbp`, `stackscope`, `multiaddr`, + audit of
|
||||||
|
`colorlog`/`bidict`/`wrapt`/`setproctitle`).
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Profiling showed the issue's dep-list only accounted
|
||||||
|
for ~20ms; the dominant cost (~244ms) was
|
||||||
|
`log.get_logger()`'s `get_caller_mod()` calling
|
||||||
|
`inspect.stack()` at module level in ~39 modules —
|
||||||
|
each call walks every stack frame (deep during nested
|
||||||
|
imports) and scans `sys.modules` per frame via
|
||||||
|
`inspect.getmodule()`.
|
||||||
|
|
||||||
|
Changes, in impact order:
|
||||||
|
|
||||||
|
1. `get_caller_mod()` -> `sys._getframe()` +
|
||||||
|
`f_globals['__name__']` `sys.modules` lookup
|
||||||
|
(~240ms saved).
|
||||||
|
2. Issue's lazy-import checklist: `bidict`,
|
||||||
|
`multiaddr`, `colorlog`, `wrapt` moved to
|
||||||
|
`TYPE_CHECKING`/function-local imports;
|
||||||
|
`platformdirs` function-local; `asyncio` +
|
||||||
|
`.to_asyncio` deferred out of the `devx.debug` +
|
||||||
|
`spawn._entry` eager paths (~15ms saved).
|
||||||
|
3. PEP 562 `__getattr__` on `tractor/__init__.py`
|
||||||
|
preserving public `tractor.to_asyncio` attr access.
|
||||||
|
|
||||||
|
Results: `import tractor` 0.42s -> ~0.145s (~65%);
|
||||||
|
sequential `start_actor` latency 0.40-0.44s ->
|
||||||
|
~0.179s/actor. `pdbp` (needs `_repl.py` class-base
|
||||||
|
restructure) + `platformdirs` (needs
|
||||||
|
`UDSAddress.def_bindspace` protocol rework) documented
|
||||||
|
as follow-ups.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/log.py` — `get_caller_mod()` perf fix +
|
||||||
|
lazy `colorlog`
|
||||||
|
- `tractor/__init__.py` — PEP 562 lazy `to_asyncio`
|
||||||
|
- `tractor/discovery/_addr.py` — `bidict` ->
|
||||||
|
`TYPE_CHECKING`
|
||||||
|
- `tractor/discovery/_multiaddr.py` — lazy `multiaddr`
|
||||||
|
- `tractor/ipc/_tcp.py`, `tractor/ipc/_uds.py` —
|
||||||
|
`Multiaddr` -> `TYPE_CHECKING`
|
||||||
|
- `tractor/runtime/_state.py` — lazy `platformdirs`
|
||||||
|
- `tractor/devx/_frame_stack.py` — lazy `pdbp` +
|
||||||
|
`wrapt`
|
||||||
|
- `tractor/devx/debug/_trace.py`,
|
||||||
|
`tractor/devx/debug/_tty_lock.py` — lazy `asyncio` +
|
||||||
|
`.to_asyncio`
|
||||||
|
- `tractor/spawn/_entry.py` — lazy
|
||||||
|
`run_as_asyncio_guest`
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
None yet — pending user review at the 12:50PM EST
|
||||||
|
pause gate (test-suite results reported in-session).
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
---
|
||||||
|
model: claude-fable-5
|
||||||
|
service: claude
|
||||||
|
timestamp: 2026-07-02T15:56:26Z
|
||||||
|
git_ref: 65bf9df5
|
||||||
|
diff_cmd: git diff main..wkt/boot_latency_470
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw AI output — gh #470 `import tractor` latency trim
|
||||||
|
|
||||||
|
All generated code is committed on the
|
||||||
|
`wkt/boot_latency_470` branch; per diff-ref mode each
|
||||||
|
file's content is referenced via its diff instead of
|
||||||
|
copied verbatim.
|
||||||
|
|
||||||
|
## Profiling findings (verbatim analysis output)
|
||||||
|
|
||||||
|
Baseline: `import tractor` ~0.39-0.42s wall.
|
||||||
|
|
||||||
|
`python -X importtime` + `cProfile` traced the cost NOT
|
||||||
|
primarily to third-party deps (the issue's hypothesis)
|
||||||
|
but to `tractor/log.py:get_logger()` calling
|
||||||
|
`get_caller_mod()` -> `inspect.stack()` at module level
|
||||||
|
in ~39 tractor modules:
|
||||||
|
|
||||||
|
- `inspect.stack()` builds `FrameInfo` (incl. src-file
|
||||||
|
and line-context resolution) for EVERY frame on the
|
||||||
|
stack; during nested imports the stack is dozens of
|
||||||
|
importlib frames deep.
|
||||||
|
- each `FrameInfo` resolution calls
|
||||||
|
`inspect.getmodule()` which scans all of
|
||||||
|
`sys.modules` per frame (1.4M `ismodule()` calls in
|
||||||
|
one profiled import).
|
||||||
|
- aggregate: ~244ms of tractor-own module "self" time
|
||||||
|
vs ~20ms for ALL the issue-listed third-party deps
|
||||||
|
(`pdbp` ~10ms, `bidict` ~4.5ms, `multiaddr` ~3.5ms,
|
||||||
|
`wrapt`/`colorlog` ~1ms each); `trio` itself is
|
||||||
|
~70-100ms and unavoidable.
|
||||||
|
|
||||||
|
## Generated changes
|
||||||
|
|
||||||
|
> `git diff main..wkt/boot_latency_470 -- tractor/log.py`
|
||||||
|
|
||||||
|
`get_caller_mod()` rewritten from `inspect.stack()` +
|
||||||
|
`inspect.getmodule()` to `sys._getframe(frames_up)` +
|
||||||
|
`frame.f_globals['__name__']` -> `sys.modules` lookup
|
||||||
|
(O(1) vs O(stack x sys.modules)). Unused `inspect`
|
||||||
|
imports dropped; `FrameType` imported from `types`.
|
||||||
|
Also `colorlog` lazy-imported inside
|
||||||
|
`get_console_log()`.
|
||||||
|
|
||||||
|
> `git diff main..wkt/boot_latency_470 -- tractor/discovery/_addr.py`
|
||||||
|
|
||||||
|
`bidict` import moved under `TYPE_CHECKING`
|
||||||
|
(annotation-only use; `_address_types` is a plain dict
|
||||||
|
literal).
|
||||||
|
|
||||||
|
> `git diff main..wkt/boot_latency_470 -- tractor/discovery/_multiaddr.py`
|
||||||
|
|
||||||
|
`from __future__ import annotations` added; `multiaddr`
|
||||||
|
import moved under `TYPE_CHECKING` + function-local
|
||||||
|
imports in `mk_maddr()`/`parse_maddr()`.
|
||||||
|
|
||||||
|
> `git diff main..wkt/boot_latency_470 -- tractor/ipc/_tcp.py tractor/ipc/_uds.py`
|
||||||
|
|
||||||
|
`Multiaddr` imports moved under `TYPE_CHECKING`
|
||||||
|
(annotation-only in both transports).
|
||||||
|
|
||||||
|
> `git diff main..wkt/boot_latency_470 -- tractor/runtime/_state.py`
|
||||||
|
|
||||||
|
`platformdirs` lazy-imported inside `get_rt_dir()`
|
||||||
|
(NOTE: still imported eagerly via
|
||||||
|
`UDSAddress.def_bindspace` class-var eval; see
|
||||||
|
follow-ups).
|
||||||
|
|
||||||
|
> `git diff main..wkt/boot_latency_470 -- tractor/devx/_frame_stack.py`
|
||||||
|
|
||||||
|
`pdbp` + `wrapt` lazy-imported inside
|
||||||
|
`hide_runtime_frames()` / `api_frame()` respectively.
|
||||||
|
|
||||||
|
> `git diff main..wkt/boot_latency_470 -- tractor/devx/debug/_trace.py tractor/devx/debug/_tty_lock.py`
|
||||||
|
|
||||||
|
`asyncio` moved to `TYPE_CHECKING` + call-site local
|
||||||
|
imports (`asyncio.current_task()` sites);
|
||||||
|
`tractor.to_asyncio.run_trio_task_in_future` imports
|
||||||
|
moved into the infected-aio runtime branches.
|
||||||
|
|
||||||
|
> `git diff main..wkt/boot_latency_470 -- tractor/spawn/_entry.py`
|
||||||
|
|
||||||
|
`run_as_asyncio_guest` import moved into the
|
||||||
|
`infect_asyncio=True` branches of `_mp_main()` /
|
||||||
|
`_trio_main()`.
|
||||||
|
|
||||||
|
> `git diff main..wkt/boot_latency_470 -- tractor/__init__.py`
|
||||||
|
|
||||||
|
PEP 562 module `__getattr__` added so
|
||||||
|
`tractor.to_asyncio` attr-access still works (required
|
||||||
|
by `tests/test_child_manages_service_nursery.py` and
|
||||||
|
any downstream user) while keeping `asyncio` off the
|
||||||
|
eager import path.
|
||||||
|
|
||||||
|
## Measured results (verbatim)
|
||||||
|
|
||||||
|
- `import tractor`: 0.39-0.42s -> ~0.145s (~65% cut)
|
||||||
|
- `start_actor` spawn+boot+reg+cancel: ~0.40-0.44s ->
|
||||||
|
~0.179s/actor (n=5 sequential, warm parent)
|
||||||
|
- post-change eager-module check: only `pdbp` +
|
||||||
|
`platformdirs` of the issue's list remain eager.
|
||||||
|
|
||||||
|
## Known follow-ups (not implemented, deadline-bound)
|
||||||
|
|
||||||
|
- `pdbp` (~10ms): still eager via
|
||||||
|
`devx/debug/_repl.py` class bases
|
||||||
|
(`class PdbREPL(pdbp.Pdb)`) + `_tty_lock.py`
|
||||||
|
module-level `@pdbp.hideframe`; needs `_repl`
|
||||||
|
restructure + PEP 562 in `devx.debug.__init__`.
|
||||||
|
- `platformdirs` (~1.5ms): eager via
|
||||||
|
`UDSAddress.def_bindspace: ClassVar = get_rt_dir()`
|
||||||
|
class-body call; needs `Address`-protocol rework of
|
||||||
|
`def_bindspace` to a lazy accessor.
|
||||||
|
- `stackscope` + `setproctitle`: already lazy/absent —
|
||||||
|
no change needed.
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
model: claude-fable-5
|
||||||
|
service: claude
|
||||||
|
session: f6c84722-471a-4458-9a80-e453fea9029f
|
||||||
|
timestamp: 2026-07-02T16:58:06Z
|
||||||
|
git_ref: a34aaf98
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260702T165806Z_a34aaf98_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Follow-up round in the same session as the
|
||||||
|
`tractor.to_actor` landing (see
|
||||||
|
`20260702T154255Z_65bf9df5_prompt_io.md`). After
|
||||||
|
committing that work the user green-lit the deferred
|
||||||
|
items:
|
||||||
|
|
||||||
|
> go go go on this with what time you have left, in
|
||||||
|
> particular see if you can get the _ria_nursery
|
||||||
|
> removal going!
|
||||||
|
|
||||||
|
then extended the deadline twice to iterate on the
|
||||||
|
discovered hang:
|
||||||
|
|
||||||
|
> continue on this up until a 12:58:30 deadline
|
||||||
|
|
||||||
|
and finally chose "Commit step A now" from the
|
||||||
|
next-steps prompt.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Step A of the `._ria_nursery` removal (issue #477): hoist
|
||||||
|
`.run_in_actor()` result-reaping out of the spawn
|
||||||
|
backends into the `ActorNursery` machinery so ria
|
||||||
|
children spawn via the default daemon nursery,
|
||||||
|
|
||||||
|
- new `_supervise._reap_ria_portals()` runs one
|
||||||
|
`_spawn.cancel_on_completion()` task per ria child
|
||||||
|
AFTER `._join_procs` is set; happy path awaits it
|
||||||
|
right after `._join_procs.set()`.
|
||||||
|
- error path SEQUENCES: snapshot ria
|
||||||
|
`(portal, subactor)` pairs -> `await an.cancel()` ->
|
||||||
|
0.5s-bounded reap. Two failed intermediates informed
|
||||||
|
this: a concurrent reap+cancel DEADLOCKED
|
||||||
|
`test_multierror`; a 3s bound blew
|
||||||
|
`test_cancel_while_childs_child_in_sync_sleep`'s
|
||||||
|
`fail_after` deadline.
|
||||||
|
- backends (`spawn/_trio.py`, `spawn/_mp.py`) lose the
|
||||||
|
`._cancel_after_result_on_exit` membership branch,
|
||||||
|
per-child reaper nursery + dead imports.
|
||||||
|
- design/probe-history doc:
|
||||||
|
`ai/conc-anal/ria_nursery_removal_plan.md` (from an
|
||||||
|
agent-verified machinery map).
|
||||||
|
|
||||||
|
Verification: `test_cancellation.py` fully green
|
||||||
|
(20 passed, 1 xfailed) incl. the previously-hung
|
||||||
|
`test_multierror`; `test_to_actor`+`test_spawning`
|
||||||
|
20/20; bounded full-suite gate SIGINT'd ~30s early at
|
||||||
|
303 passed / 0 failures (user opted to commit on that
|
||||||
|
signal, deferring the unbounded re-run to step-B
|
||||||
|
verification).
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_supervise.py` — `_reap_ria_portals()`
|
||||||
|
+ two call-sites; `run_in_actor()` off the ria nursery
|
||||||
|
- `tractor/spawn/_trio.py` — reaper branch + import drop
|
||||||
|
- `tractor/spawn/_mp.py` — same as `_trio.py`
|
||||||
|
- `ai/conc-anal/ria_nursery_removal_plan.md` — plan +
|
||||||
|
probe history
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
None yet — committed via the drafted
|
||||||
|
`.claude/git_commit_msg_ria_step_a.md` (user-driven
|
||||||
|
`git commit --edit`).
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
---
|
||||||
|
model: claude-fable-5
|
||||||
|
service: claude
|
||||||
|
timestamp: 2026-07-02T16:58:06Z
|
||||||
|
git_ref: a34aaf98
|
||||||
|
diff_cmd: git diff a34aaf98..wkt/to_actor_subpkg
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw AI output (diff-ref mode)
|
||||||
|
|
||||||
|
Step-A code is committed on `wkt/to_actor_subpkg`
|
||||||
|
directly after `a34aaf98`; per diff-ref mode the verbatim
|
||||||
|
content is reachable via the pointers below.
|
||||||
|
|
||||||
|
## Generated files
|
||||||
|
|
||||||
|
> `git diff a34aaf98..wkt/to_actor_subpkg -- tractor/runtime/_supervise.py`
|
||||||
|
|
||||||
|
New `_reap_ria_portals(an, errors, ria_children=None)`
|
||||||
|
helper (one `_spawn.cancel_on_completion()` task per ria
|
||||||
|
child under `collapse_eg()` + a local nursery);
|
||||||
|
`run_in_actor()` drops `nursery=self._ria_nursery`; happy
|
||||||
|
path awaits the reap right after `._join_procs.set()`;
|
||||||
|
inner error handler snapshots ria pairs, runs
|
||||||
|
`await an.cancel()` then a `move_on_after(0.5)`-bounded
|
||||||
|
reap over the snapshot.
|
||||||
|
|
||||||
|
> `git diff a34aaf98..wkt/to_actor_subpkg -- tractor/spawn/_trio.py`
|
||||||
|
> `git diff a34aaf98..wkt/to_actor_subpkg -- tractor/spawn/_mp.py`
|
||||||
|
|
||||||
|
Both backends: the post-`_join_procs` block collapses to
|
||||||
|
a bare `soft_kill()` (membership branch, per-child reaper
|
||||||
|
nursery, reaper-cancel logging and the now-unused
|
||||||
|
`cancel_on_completion` imports all removed).
|
||||||
|
|
||||||
|
> `git diff a34aaf98..wkt/to_actor_subpkg -- ai/conc-anal/ria_nursery_removal_plan.md`
|
||||||
|
|
||||||
|
Agent-verified machinery map, 3-step design (A/B/C),
|
||||||
|
probe history (deadlock -> sequencing fix -> bound
|
||||||
|
tightening) and risk register.
|
||||||
|
|
||||||
|
## Test runs (verbatim)
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_cancellation.py: 20 passed, 1 xfailed in 77.28s
|
||||||
|
tests/test_to_actor.py + tests/test_spawning.py: 20 passed
|
||||||
|
full-suite (SIGINT at 540s bound, ~30s short):
|
||||||
|
303 passed, 9 skipped, 1 xfailed, 2 xpassed in 539.42s
|
||||||
|
```
|
||||||
|
|
||||||
|
Failed intermediates (not in final diff): concurrent
|
||||||
|
reap+`an.cancel()` deadlocked `test_multierror`; a 3s
|
||||||
|
reap bound blew
|
||||||
|
`test_cancel_while_childs_child_in_sync_sleep`'s
|
||||||
|
`fail_after` deadline.
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
---
|
||||||
|
model: claude-opus-4-8
|
||||||
|
service: claude
|
||||||
|
session: c7860302-6a33-4d92-821c-0807d68be464
|
||||||
|
timestamp: 2026-07-02T17:22:33Z
|
||||||
|
git_ref: 5cd190c5
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260702T172233Z_5cd190c5_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Branched session (`to_actor_subpkg_POST_WEEKLY_CUTOFF_2OPUS`,
|
||||||
|
now on Opus 4.8) continuing the `._ria_nursery` removal after
|
||||||
|
step A committed + pushed (`5cd190c5`/`99310269`):
|
||||||
|
|
||||||
|
> ok continue where your superior left off please
|
||||||
|
|
||||||
|
then, after the commit-split discussion:
|
||||||
|
|
||||||
|
> yup committed and pushed so take a look ma fren.
|
||||||
|
|
||||||
|
i.e. green-lighting step-B prep against the committed step-A
|
||||||
|
tree.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Step B of the `._ria_nursery` removal (issue #477): delete
|
||||||
|
the now-vestigial secondary nursery,
|
||||||
|
|
||||||
|
- collapse the inner `async with (collapse_eg(),
|
||||||
|
trio.open_nursery() as ria_nursery)` layer in
|
||||||
|
`_open_and_supervise_one_cancels_all_nursery` — `da_nursery`
|
||||||
|
becomes the single nursery for ALL subactors.
|
||||||
|
- `ActorNursery.__init__` drops the `ria_nursery` param + the
|
||||||
|
`self._ria_nursery` attr; `start_actor()` drops its
|
||||||
|
`nursery=` escape-hatch param.
|
||||||
|
- `._cancel_after_result_on_exit` kept (ria-child
|
||||||
|
discriminator for `_reap_ria_portals()`).
|
||||||
|
|
||||||
|
Verified behavior-preserving via a first-principles argument
|
||||||
|
(zero-task nursery = a bare checkpoint) + the targeted gate
|
||||||
|
(`test_cancellation test_spawning test_local test_rpc
|
||||||
|
test_to_actor` = 49 passed, 1 xfailed on trio). The two
|
||||||
|
error handlers were deliberately NOT merged — that changes
|
||||||
|
propagation semantics and is deferred to its own PR (TODO
|
||||||
|
left at the outer `except`).
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_supervise.py` — collapse the ria nursery
|
||||||
|
layer + drop the ctor/`start_actor` params + refresh the
|
||||||
|
now-stale nursery comments
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
None yet — committed via the drafted
|
||||||
|
`.claude/git_commit_msg_ria_step_b.md` (user-driven
|
||||||
|
`git commit --edit`).
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
---
|
||||||
|
model: claude-opus-4-8
|
||||||
|
service: claude
|
||||||
|
timestamp: 2026-07-02T17:22:33Z
|
||||||
|
git_ref: 5cd190c5
|
||||||
|
diff_cmd: git diff 5cd190c5..wkt/to_actor_subpkg
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw AI output (diff-ref mode)
|
||||||
|
|
||||||
|
Step-B code lives on `wkt/to_actor_subpkg` after `5cd190c5`;
|
||||||
|
per diff-ref mode the verbatim content is reachable via the
|
||||||
|
pointer below.
|
||||||
|
|
||||||
|
## Generated files
|
||||||
|
|
||||||
|
> `git diff 5cd190c5..wkt/to_actor_subpkg -- tractor/runtime/_supervise.py`
|
||||||
|
|
||||||
|
- `ActorNursery.__init__`: `ria_nursery` param removed;
|
||||||
|
`self._ria_nursery = ria_nursery` block deleted;
|
||||||
|
`_cancel_after_result_on_exit` comment refreshed.
|
||||||
|
- `start_actor()`: `nursery=` param removed; body uses
|
||||||
|
`self._da_nursery.start(...)` directly.
|
||||||
|
- `_open_and_supervise_one_cancels_all_nursery()`: the inner
|
||||||
|
`async with (collapse_eg(), trio.open_nursery() as
|
||||||
|
ria_nursery)` layer removed; `an = ActorNursery(actor,
|
||||||
|
da_nursery, errors)` constructed once under the single
|
||||||
|
`da_nursery`; the inner-try body de-indented one level;
|
||||||
|
both error handlers retained; the da-nursery lead comment
|
||||||
|
and the outer-`except` TODO refreshed to describe the
|
||||||
|
single-nursery reality + flag the (deferred) handler-merge.
|
||||||
|
|
||||||
|
> `git diff 5cd190c5..wkt/to_actor_subpkg -- ai/conc-anal/ria_nursery_removal_plan.md`
|
||||||
|
|
||||||
|
Added a "Step-B outcome" section (collapse rationale,
|
||||||
|
handler-merge deferral, safety argument, gate result).
|
||||||
|
|
||||||
|
## Test runs (verbatim)
|
||||||
|
|
||||||
|
```
|
||||||
|
targeted gate (trio):
|
||||||
|
tests/test_cancellation.py tests/test_spawning.py
|
||||||
|
tests/test_local.py tests/test_rpc.py tests/test_to_actor.py
|
||||||
|
-> 49 passed, 1 xfailed in 88.62s
|
||||||
|
|
||||||
|
signature checks:
|
||||||
|
ActorNursery.__init__ params: ['self', 'actor', 'da_nursery', 'errors']
|
||||||
|
start_actor has nursery param: False
|
||||||
|
|
||||||
|
full-suite gate: in progress (see stepB_full.log)
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
---
|
||||||
|
model: claude-opus-4-8
|
||||||
|
service: claude
|
||||||
|
session: c7860302-6a33-4d92-821c-0807d68be464
|
||||||
|
timestamp: 2026-07-02T22:25:44Z
|
||||||
|
git_ref: 9201a2ed
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260702T222544Z_9201a2ed_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continuing on branch `drop_ria_nursery` after step B
|
||||||
|
(`9201a2ed`). User green-lit the deferred error-handler merge:
|
||||||
|
|
||||||
|
> yes, i checked us out on a new branch for the ria-tn removal;
|
||||||
|
> work off the new wkt branch drop_ria_nursery.
|
||||||
|
|
||||||
|
and (re the migration) "nope, later we'll do this", plus:
|
||||||
|
|
||||||
|
> do ensure the test suite is green in the current state and
|
||||||
|
> those going forward ya?
|
||||||
|
|
||||||
|
and the standing reminder: no commits without a human ack.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Step B2 of the `._ria_nursery` removal (issue #477): merge the
|
||||||
|
two nested error handlers in
|
||||||
|
`_open_and_supervise_one_cancels_all_nursery` into one, now
|
||||||
|
that the 2ndary nursery (whose `__aexit__` the outer handler
|
||||||
|
originally guarded) is gone.
|
||||||
|
|
||||||
|
Trace-driven justification (see
|
||||||
|
`ai/conc-anal/ria_nursery_removal_plan.md` Step-B2 section):
|
||||||
|
the old inner handler records `errors[uid]` as its FIRST
|
||||||
|
action, so `errors` is always non-empty by the time control
|
||||||
|
could reach the outer handler — making the outer handler's
|
||||||
|
`raise` DEAD (always superseded by the `finally`'s
|
||||||
|
raise-from-`errors`). Collapsing to a single
|
||||||
|
`except BaseException as _scope_err` + the existing `finally`
|
||||||
|
is therefore behavior-preserving.
|
||||||
|
|
||||||
|
- removed the outer `try` + the
|
||||||
|
`except (Exception, BaseExceptionGroup, trio.Cancelled)`
|
||||||
|
handler; de-indented the inner handler one level.
|
||||||
|
- dropped the now-unused `outer_err`/`inner_err` locals
|
||||||
|
(single `_scope_err`).
|
||||||
|
- net ~30 lines lighter.
|
||||||
|
|
||||||
|
Gated hard: baseline (pre-B2 tip) = 300 passed + 1
|
||||||
|
load-induced `TooSlowError` flake (passes 6/6 in isolation);
|
||||||
|
B2 error/cancel gate (`test_cancellation remote_exc_relay
|
||||||
|
inter_peer_cancellation advanced_faults oob_cancellation
|
||||||
|
to_actor spawning local rpc`) = 71 passed, 1 xfailed; B2
|
||||||
|
full-suite run following.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_supervise.py` — collapse the two handlers
|
||||||
|
into one; drop `outer_err`/`inner_err`
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
None yet — committed via the drafted
|
||||||
|
`.claude/git_commit_msg_ria_b2.md` (user-driven
|
||||||
|
`git commit --edit`).
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
---
|
||||||
|
model: claude-opus-4-8
|
||||||
|
service: claude
|
||||||
|
timestamp: 2026-07-02T22:25:44Z
|
||||||
|
git_ref: 9201a2ed
|
||||||
|
diff_cmd: git diff 9201a2ed..drop_ria_nursery
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw AI output (diff-ref mode)
|
||||||
|
|
||||||
|
Step-B2 code lives on `drop_ria_nursery` after `9201a2ed`; per
|
||||||
|
diff-ref mode the verbatim content is reachable via the pointer
|
||||||
|
below.
|
||||||
|
|
||||||
|
## Generated files
|
||||||
|
|
||||||
|
> `git diff 9201a2ed..drop_ria_nursery -- tractor/runtime/_supervise.py`
|
||||||
|
|
||||||
|
`_open_and_supervise_one_cancels_all_nursery`:
|
||||||
|
- removed the outer `try:` wrapper and the
|
||||||
|
`except (Exception, BaseExceptionGroup, trio.Cancelled) as
|
||||||
|
_outer_err:` safety-net handler.
|
||||||
|
- the former inner `except BaseException` is now THE handler,
|
||||||
|
renamed local `_inner_err` -> `_scope_err`, de-indented one
|
||||||
|
level; it sets `an._scope_error`, records `errors[uid]`,
|
||||||
|
waits on the debugger, `_join_procs.set()`, then a shielded
|
||||||
|
classify/log + snapshot-ria + `an.cancel()` + 0.5s-bounded
|
||||||
|
`_reap_ria_portals()`. No re-raise (the `finally` raises
|
||||||
|
from `errors`).
|
||||||
|
- `finally` block unchanged.
|
||||||
|
- dropped the `outer_err`/`inner_err` local decls at fn top.
|
||||||
|
|
||||||
|
(The diff is large — ~119+/149- — because de-indenting the
|
||||||
|
handler body one level rewrites every line in the block; the
|
||||||
|
logic delta is just "two handlers -> one".)
|
||||||
|
|
||||||
|
## Test runs (verbatim)
|
||||||
|
|
||||||
|
```
|
||||||
|
baseline (pre-B2, step-B tip 9201a2ed), full suite
|
||||||
|
(dynamic_pub_sub deselected):
|
||||||
|
1 failed, 300 passed, 9 skipped, 2 deselected, 1 xfailed,
|
||||||
|
2 xpassed in 1499.49s
|
||||||
|
-> the 1 failure = test_ext_types_over_ipc[...] trio.TooSlowError
|
||||||
|
(load-induced; passes 6/6 in isolation in 4.89s)
|
||||||
|
|
||||||
|
B2 error/cancel gate:
|
||||||
|
tests/test_cancellation test_remote_exc_relay
|
||||||
|
test_inter_peer_cancellation test_advanced_faults
|
||||||
|
test_oob_cancellation test_to_actor test_spawning test_local
|
||||||
|
test_rpc
|
||||||
|
-> 71 passed, 1 xfailed in 125.26s
|
||||||
|
|
||||||
|
B2 full-suite run: see b2_full.log
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
---
|
||||||
|
model: claude-fable-5
|
||||||
|
service: claude
|
||||||
|
session: 6db64ac6-6986-4505-9343-df4ee31e67db
|
||||||
|
timestamp: 2026-07-06T17:28:18Z
|
||||||
|
git_ref: ad42871e
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260706T172818Z_ad42871e_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Resuming the `drop_ria_nursery` (wkt) branch after the human's
|
||||||
|
review pause at `ba4af0ad`, with the directive:
|
||||||
|
|
||||||
|
> we need to resolve the remaining outstandings ... to finish
|
||||||
|
> this endeavour we ideally,
|
||||||
|
> - finish the ria-tn hoist into the machinery in `to_actor` if
|
||||||
|
> possible.
|
||||||
|
> - avoid supporting the `.run_in_actor()` non-blocking API
|
||||||
|
> going forward other then if we can re-wrap `to_actor.run()`
|
||||||
|
> per prior discussion with opus4.8.
|
||||||
|
> - get the test harness completely green locally and in CI -
|
||||||
|
> AND ESPECIALLY without any hangs due to the "the old
|
||||||
|
> spawn-backend reaper raced `soft_kill()`'s scope-cancel"
|
||||||
|
> - update all docs to reflect these changes once we're happy.
|
||||||
|
|
||||||
|
plus mid-flight additions:
|
||||||
|
|
||||||
|
> make sure both all the test and example code uses the naming
|
||||||
|
> `an: ActorNursery` for variable names
|
||||||
|
|
||||||
|
> where we at in terms of a potential hackaround design for
|
||||||
|
> supporting a to_actor.run_async() ... without requiring the
|
||||||
|
> same malarky ... with a `trio.Event` relay ... i'm totes fine
|
||||||
|
> with rejigging the iface to NOT return a Portal as well.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Completed the entire #477 `run_in_actor` removal on
|
||||||
|
`drop_ria_nursery` (16 commits this session), gated per-commit:
|
||||||
|
|
||||||
|
- ported the remaining `test_cancellation` groups
|
||||||
|
(`some_cancels_all`, `nested_multierrors`+`spawn_and_error`,
|
||||||
|
SIGINT + sync-sleep) — one-shots become concurrent
|
||||||
|
`to_actor.run()` tasks in local task-nurseries; BEG-shape
|
||||||
|
assertions loosened for cancel-on-first + the runtime's
|
||||||
|
`collapse_eg()` single-member unwrap (a fully-raced nested
|
||||||
|
tree relays a bare annotated `RemoteActorError` chain).
|
||||||
|
- fixed a pre-existing `UnboundLocalError` (`timeout` `match`
|
||||||
|
had no default arm for non-trio/MTF backends).
|
||||||
|
- ported `test_dynamic_pub_sub`, 4 non-debugging examples, all
|
||||||
|
8 `debugging/` examples (debugger suite byte-identical,
|
||||||
|
28p/6s; `multi_subactors` introduces the "collect don't
|
||||||
|
cancel" reap-all replacement pattern), 8 docs pages + the
|
||||||
|
`experimental/_pubsub` docstring.
|
||||||
|
- EXCISED the API + cluster: `run_in_actor`,
|
||||||
|
`_reap_ria_portals`, `_cancel_after_result_on_exit`,
|
||||||
|
`Portal._submit_for_result/_expect_result_ctx/
|
||||||
|
wait_for_result/result`, `exhaust_portal`,
|
||||||
|
`cancel_on_completion`, `NoResult` — net -402 lines. The
|
||||||
|
reap-hang class dissolves structurally (result-waits now only
|
||||||
|
in caller task-scope).
|
||||||
|
- found + fixed a real migration race: mutual-rendezvous peers
|
||||||
|
(`test_trynamic_trio`, `a_trynamic_first_scene.py`) flaked
|
||||||
|
because an eagerly-reaped one-shot dies while its peer still
|
||||||
|
dials the registry-resolved (dead) sockaddr — such peers now
|
||||||
|
pin lifetimes via `start_actor()` + concurrent `Portal.run()`
|
||||||
|
+ explicit `an.cancel()`.
|
||||||
|
- `an: ActorNursery` naming sweep across tests/examples (±82
|
||||||
|
lines, scoped renames, prose untouched).
|
||||||
|
- parked a `to_actor.open_one_shot()` design sketch (acm +
|
||||||
|
private task-nursery over blocking `run()`; done-Event as
|
||||||
|
memo not cancel-relay; no Portal) in the plan doc.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
See commits `d01a2123..ad42871e` on `drop_ria_nursery`
|
||||||
|
(tests, examples, docs, `tractor/{runtime,spawn,to_actor,msg}`
|
||||||
|
+ `_exceptions/_context/experimental`).
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
model: claude-fable-5
|
||||||
|
service: claude
|
||||||
|
timestamp: 2026-07-06T17:28:18Z
|
||||||
|
git_ref: ad42871e
|
||||||
|
diff_cmd: git diff ba4af0ad..ad42871e
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw AI output (diff-ref mode)
|
||||||
|
|
||||||
|
This session's output spans the 16 migration/excision commits
|
||||||
|
`d01a2123..ad42871e` on `drop_ria_nursery`; per diff-ref mode
|
||||||
|
the verbatim content is reachable via the pointer below.
|
||||||
|
|
||||||
|
## Generated files
|
||||||
|
|
||||||
|
> `git diff ba4af0ad..ad42871e`
|
||||||
|
|
||||||
|
Commit-wise (each `Gate:`-footed msg documents its own module
|
||||||
|
gate):
|
||||||
|
|
||||||
|
- `d01a2123` port `test_some_cancels_all`
|
||||||
|
- `697c6152` fix unbound `timeout` (non-trio/MTF `match` arm)
|
||||||
|
- `fa8799d5` port `test_nested_multierrors`
|
||||||
|
- `f11754ce` port SIGINT + sync-sleep cancel tests
|
||||||
|
- `cb6202e3` port `test_dynamic_pub_sub`
|
||||||
|
- `d8af5f12` port non-debugging examples
|
||||||
|
- `a3057cb2` port debugging examples (+ `test_debugger`
|
||||||
|
nested-nurseries final-shape expectations)
|
||||||
|
- `d6bed7c4` port docs (8 rst pages)
|
||||||
|
- `07e1669e` fix stale `@pub` docstring example
|
||||||
|
- `2a59cefb` REMOVE `run_in_actor()` + the ria reap cluster
|
||||||
|
(net -402 lines)
|
||||||
|
- `a297a32a` fix mutual-rendezvous premature-reap race
|
||||||
|
- `ad42871e` `an: ActorNursery` naming sweep
|
||||||
|
|
||||||
|
Plan/design record updated in
|
||||||
|
`ai/conc-anal/ria_nursery_removal_plan.md` (RESOLVED section +
|
||||||
|
the `to_actor.open_one_shot()` follow-up sketch).
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
---
|
||||||
|
model: claude-opus-5
|
||||||
|
service: claude
|
||||||
|
session: 7b9c97c4-fff7-4ac4-97fb-35720453308e
|
||||||
|
timestamp: 2026-08-13T00:11:02Z
|
||||||
|
git_ref: 27c34aeb
|
||||||
|
scope: docs+code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260813T001102Z_27c34aeb_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
> draft hyper detailed implementation plans for [three]
|
||||||
|
> prospective new transport (tpt) backends for tractor's `.ipc`
|
||||||
|
> layer, from four GitHub issues: TIPC (gh #378) using built-in
|
||||||
|
> linux socket API w/ `trio` interfacing, leveraging TIPC's
|
||||||
|
> built-in discovery machinery; QUIC (gh #353) using the `iroh`
|
||||||
|
> lib, ideally with the py asyncio support (via ffi) rewritten
|
||||||
|
> for trio; wg (gh #482 and/or #443) with other shuttle-able
|
||||||
|
> tpts, using `pyroute2`, as much trio wrapping as possible
|
||||||
|
> where any other async support can be replaced.
|
||||||
|
|
||||||
|
With constraints: "be only slightly speculative"; realistic given
|
||||||
|
the linked info; the plans must be workable "by another
|
||||||
|
model/provider without much discrepancy on the design or lib
|
||||||
|
selections needed"; do it all in a new `/open-wkt` off main; and
|
||||||
|
be "as modular and modern-async-python-as-nearly-functional-style
|
||||||
|
as possible."
|
||||||
|
|
||||||
|
Key follow-up prompts that changed the design mid-session:
|
||||||
|
|
||||||
|
> wg requires binding a socket on both ends of the tunnel, the
|
||||||
|
> new support we added to `py-multiaddr` in their #108 enables
|
||||||
|
> declaring this in the maddr? […] so the
|
||||||
|
> `/ip4/10.0.0.1/udp/666/ip4/192.168.0.13/tcp/80` bullet example
|
||||||
|
> from #443 isn't correct for a wg tunnel maddr and would
|
||||||
|
> actually end up looking like,
|
||||||
|
> `/ip4/10.0.0.1/udp/666/wg/<pub-key>/ip4/192.168.0.13/tcp/80` no?
|
||||||
|
|
||||||
|
> right, we're likely going to need less `tuple`-formed
|
||||||
|
> `UnwrappedAddress` usage to make them all work and/or to extend
|
||||||
|
> the unwrapped form with a proto-str, likely the key from
|
||||||
|
> `multiaddrs` no?
|
||||||
|
|
||||||
|
> better encapsulating `UnwrappedAddress` for use externally is
|
||||||
|
> really way overdue; we should likely be encouraging (if not
|
||||||
|
> preventing unwrapped) wrapped addrs always much like other
|
||||||
|
> (std) libs, `ipaddress` comes to mind.
|
||||||
|
|
||||||
|
> so iirc netns mgmt becomes a lower-level runtime config-api
|
||||||
|
> rather then an actor-app-code API which can be dynamically
|
||||||
|
> changed once the actor is up ya?
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
A research-and-planning branch, `ng_tpts_planning` — **no runtime
|
||||||
|
code touched**. Five plan docs under `ai/tpt-backends/` plus a
|
||||||
|
corrected `wg` example set under `examples/multihost/wg_lan/`,
|
||||||
|
across 9 commits.
|
||||||
|
|
||||||
|
Three design conclusions came out of the planning that outlive
|
||||||
|
the branch:
|
||||||
|
|
||||||
|
1. **`wg` is a bindspace, not a `MsgTransport`.** The `/wg/`
|
||||||
|
maddr segment is *infix*: bearer (underlay ip+udp
|
||||||
|
`ListenPort`, bound by the kernel) → `/wg/u<key>` (an
|
||||||
|
identity, bound by nothing) → overlay (the only part tractor
|
||||||
|
binds). Verified empirically by installing
|
||||||
|
`baudco/py-multiaddr@wg_support` (py-multiaddr#108) in a
|
||||||
|
throwaway venv and round-tripping each candidate form; gh
|
||||||
|
#482's original suffix form parses but is semantically
|
||||||
|
inverted.
|
||||||
|
2. **Proto-key the `UnwrappedAddress` form**, spelled with the
|
||||||
|
`multiaddr` protocol names, dispatched via
|
||||||
|
`_address_types[addr[0]]`. Kills a whole collision class
|
||||||
|
(TIPC's `(str, int)` ≡ `TCPAddress`; iroh's `(str, str)`
|
||||||
|
swallowed by the UDS case) and is the recommended migration
|
||||||
|
*before* any new backend lands.
|
||||||
|
3. **netns is a runtime/boot-time config API, not an app-code
|
||||||
|
one** — `setns(2)` is per-thread and won't move
|
||||||
|
already-created sockets, so there is deliberately no
|
||||||
|
`await actor.enter_netns(...)`.
|
||||||
|
|
||||||
|
Also verified that `trio.SocketStream`/`SocketListener` are
|
||||||
|
address-family agnostic (no `AF_*` check anywhere), which is what
|
||||||
|
makes TIPC the cheapest of the three backends to add.
|
||||||
|
|
||||||
|
Four related issues were annotated with the results (#378, #353,
|
||||||
|
#482, #443); #443's body was rewritten to reflect the corrected
|
||||||
|
grammar, with no existing checkbox state changed.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `ai/tpt-backends/00_shared_backend_contract.md` — normative
|
||||||
|
backend duck-type contract, registration checklist, §1.1
|
||||||
|
proto-key conclusion
|
||||||
|
- `ai/tpt-backends/01_tipc_backend.md` — TIPC plan; service
|
||||||
|
addressing, `TIPC_TOP_SRV` push registry, instance-collision
|
||||||
|
hazard, step-0 probe
|
||||||
|
- `ai/tpt-backends/02_quic_iroh_backend.md` — `iroh` plan;
|
||||||
|
`uniffi`→`trio` bridge, listener/stream adapters, API-truth
|
||||||
|
table
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` — `wg`-as-bindspace
|
||||||
|
plan; verified maddr grammar, 3-owner split, netns reality
|
||||||
|
- `ai/tpt-backends/README.md` — index
|
||||||
|
- `examples/multihost/wg_lan/wg_maddr.py` — frozen `msgspec`
|
||||||
|
tunnelled addr + pure parse/render helpers; impure
|
||||||
|
`verify_wg_peer()` kept separate
|
||||||
|
- `examples/multihost/wg_lan/host_a_srv.py` — host-A actor tree
|
||||||
|
- `examples/multihost/wg_lan/host_b_client.py` — host-B dialer
|
||||||
|
- `examples/multihost/wg_lan/README.md` — grammar, owner table,
|
||||||
|
setup, "what changed vs #482"
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
Substantial human steering rather than post-hoc editing; the
|
||||||
|
corrections were applied by the model in-session after being
|
||||||
|
challenged:
|
||||||
|
|
||||||
|
- rejected an initial claim that `wg` has "nothing to bind at the
|
||||||
|
tunnel layer" and supplied the correct composed maddr form,
|
||||||
|
which forced a rewrite of plan 03 §3.2 and a retraction in the
|
||||||
|
already-posted #443 comment
|
||||||
|
- rejected a supporting claim that `/ip4/../udp/443/quic-v1` was
|
||||||
|
"also composed"
|
||||||
|
- directed the proto-key/`ipaddress`-discipline conclusion and
|
||||||
|
the netns-as-runtime-config framing, both of which were then
|
||||||
|
folded back into the docs
|
||||||
|
- chose the commit boundaries and authored all commits; ran every
|
||||||
|
`git` mutation (commit, rebase, push) themselves
|
||||||
|
|
||||||
|
One model-initiated correction pre-publication: a self-review
|
||||||
|
downgraded two overconfident claims (the `uniffi`/asyncio thesis
|
||||||
|
and TIPC duplicate-binder behaviour) to explicitly-flagged
|
||||||
|
assumptions before the #353/#378 comments were posted.
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
---
|
||||||
|
model: claude-opus-5
|
||||||
|
service: claude
|
||||||
|
timestamp: 2026-08-13T00:11:02Z
|
||||||
|
git_ref: 27c34aeb
|
||||||
|
diff_cmd: git diff main..ng_tpts_planning
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output — next-gen tpt-backend implementation plans
|
||||||
|
|
||||||
|
## Generated planning docs
|
||||||
|
|
||||||
|
> `git diff main..ng_tpts_planning -- ai/tpt-backends/`
|
||||||
|
|
||||||
|
Five markdown docs. `00_shared_backend_contract.md` is normative
|
||||||
|
and the other three are written against it so they can be worked
|
||||||
|
independently:
|
||||||
|
|
||||||
|
- **`00_shared_backend_contract.md`** — the backend duck-type
|
||||||
|
(`<Proto>Address(msgspec.Struct, frozen=True)` + module-level
|
||||||
|
`start_listener()`/`close_listener()` + a
|
||||||
|
`Msgpack<Proto>Stream(MsgpackTransport)`), the
|
||||||
|
`inspect.getmodule(self.addr)` reflection in
|
||||||
|
`Endpoint.start_listener()` that forces the Address class and
|
||||||
|
its listener fns to share a module, a 10-item registration
|
||||||
|
checklist, the dep policy, the test-harness shape, and §1.1's
|
||||||
|
proto-key conclusion (below).
|
||||||
|
- **`01_tipc_backend.md`** — service addressing via
|
||||||
|
`TIPC_ADDR_NAMESEQ` (bind/publish) and `TIPC_ADDR_NAME`
|
||||||
|
(connect/lookup), `TIPC_TOP_SRV` topology subscriptions as a
|
||||||
|
push-based registry, the `get_random()` instance-collision
|
||||||
|
hazard, and a step-0 capability-probe spike.
|
||||||
|
- **`02_quic_iroh_backend.md`** — `iroh` over
|
||||||
|
`aioquic`/`quiche`/`trio-asyncio`, a `_uniffi_trio.py` bridge
|
||||||
|
built on `TrioToken.run_sync_soon()`, `trio.abc.Listener`/
|
||||||
|
`HalfCloseableStream` adapters, and an API-truth table to fill
|
||||||
|
in during step 0.
|
||||||
|
- **`03_wg_tunnel_bindspace.md`** — `wg` as a *bindspace* rather
|
||||||
|
than a `MsgTransport`, a `TunnelledAddress` wrapper delegating
|
||||||
|
`.proto_key`/`.unwrap()` to `.inner`, `pyroute2` for layer B,
|
||||||
|
and `@acm`-managed netns/iface for layer C.
|
||||||
|
- **`README.md`** — index.
|
||||||
|
|
||||||
|
## Generated example code
|
||||||
|
|
||||||
|
> `git diff main..ng_tpts_planning -- examples/multihost/wg_lan/`
|
||||||
|
|
||||||
|
- `wg_maddr.py` — `WGTunnelledAddr(msgspec.Struct, frozen=True)`
|
||||||
|
carrying `bearer: tuple[str, int]`, `peer_pubkey: str`,
|
||||||
|
`inner: tuple[str, int]`, `inner_proto: Literal['tcp']`, plus a
|
||||||
|
`.maddr` property that re-renders the canonical form. Pure
|
||||||
|
helpers `mb_pubkey()`, `wg8_pubkey()`, `parse_wg_maddr()`, and
|
||||||
|
`_segments()` (with a marked stopgap for when the `wg` codec
|
||||||
|
isn't installed). `verify_wg_peer()` is impure **by design** and
|
||||||
|
kept out of the parse path.
|
||||||
|
- `host_a_srv.py` / `host_b_client.py` — the two-host runs; both
|
||||||
|
pass only `addr.inner` to `open_nursery()`/`open_root_actor()`.
|
||||||
|
- `README.md` — grammar, owner table, `#108`-branch install line,
|
||||||
|
tunnel setup, "what changed vs #482".
|
||||||
|
|
||||||
|
## Verified findings (non-code, verbatim)
|
||||||
|
|
||||||
|
### `trio` is address-family agnostic
|
||||||
|
|
||||||
|
Read against the installed `trio`. `SocketStream`/`SocketListener`
|
||||||
|
ctor checks are only "is a trio sock object" + `type ==
|
||||||
|
SOCK_STREAM`, plus an `OSError`-**suppressed** `SO_ACCEPTCONN`
|
||||||
|
probe. No `AF_*` check anywhere; `TCP_NODELAY`/`TCP_NOTSENT_LOWAT`
|
||||||
|
are set under `suppress(OSError)`. A TIPC `SOCK_STREAM` sock should
|
||||||
|
therefore drop straight into `trio.serve_listeners()` with the
|
||||||
|
existing `MsgpackTransport` framing, making TIPC mostly
|
||||||
|
table-registration boilerplate w/ zero new deps.
|
||||||
|
|
||||||
|
### the `wg` maddr grammar — `/wg/` is infix, not suffix
|
||||||
|
|
||||||
|
Installed `baudco/py-multiaddr@wg_support` (PR
|
||||||
|
multiformats/py-multiaddr#108) into a throwaway venv and
|
||||||
|
round-tripped every candidate form:
|
||||||
|
|
||||||
|
| maddr | `[p.name for p in m.protocols()]` |
|
||||||
|
| --- | --- |
|
||||||
|
| `/ip4/1.2.3.4/udp/51820/wg/u<k>` | `['ip4','udp','wg']` |
|
||||||
|
| `/ip4/../udp/../wg/u<k>/ip4/../tcp/..` | `['ip4','udp','wg','ip4','tcp']` |
|
||||||
|
| `/ip4/10.0.11.1/tcp/1616/wg/u<k>` | `['ip4','tcp','wg']` |
|
||||||
|
|
||||||
|
```
|
||||||
|
/ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616
|
||||||
|
\_______ bearer __________/\__ key __/\______ overlay ______/
|
||||||
|
```
|
||||||
|
|
||||||
|
Segments *before* `/wg/` are the bearer — the underlay
|
||||||
|
`(ip, udp-port)` that `wg(8)` itself listens on (`ListenPort`).
|
||||||
|
Segments *after* are the overlay endpoint, the only part tractor
|
||||||
|
binds. The third row above is #482's original suffix form: it
|
||||||
|
parses, but is semantically inverted.
|
||||||
|
|
||||||
|
Three parts, three owners — and only one is an `Endpoint`:
|
||||||
|
|
||||||
|
| part | bound by | in the runtime? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| bearer | kernel, via `wg-quick`/`pyroute2` | no |
|
||||||
|
| `/wg/u<key>` | nothing — an identity | no, verified out-of-band |
|
||||||
|
| overlay | `tractor`'s `IPCServer` | yes, as `.inner` |
|
||||||
|
|
||||||
|
### proto-key-tagged `UnwrappedAddress`
|
||||||
|
|
||||||
|
Shape-matching in `wrap_address()` does not survive four backends.
|
||||||
|
TIPC's natural unwrapped form is a `(str, int)`, indistinguishable
|
||||||
|
from `TCPAddress`; iroh's is a `(str, str)`, already swallowed by
|
||||||
|
the existing UDS case (`case (_, filename) if type(filename) is
|
||||||
|
str`). Ordering hacks and prefix-tagging only paper over it.
|
||||||
|
|
||||||
|
Recommended prerequisite for all three backends: carry an explicit
|
||||||
|
proto-key spelled with the `multiaddr` protocol name —
|
||||||
|
`('tcp', host, port)`, `('unix', path)`,
|
||||||
|
`('tipc', stype, inst, scope)` — so `wrap_address()` collapses to
|
||||||
|
`_address_types[addr[0]]` and the collision class stops existing.
|
||||||
|
This also makes the on-wire form agree with
|
||||||
|
`mk_maddr()`/`parse_maddr()` instead of being an independent
|
||||||
|
invention. It is a wire-format change (`SpawnSpec`,
|
||||||
|
`_root_mailbox`, `_registry_addrs`) plus every fixture and
|
||||||
|
downstream config, so it wants its own migration commit landed
|
||||||
|
before any new backend — and it is the moment to stop handing raw
|
||||||
|
tuples to users at all, making `Address` the public currency and
|
||||||
|
`UnwrappedAddress` an internal serialization detail (the
|
||||||
|
discipline `ipaddress` uses).
|
||||||
|
|
||||||
|
### netns is a runtime-level config API
|
||||||
|
|
||||||
|
`setns(2)` affects the calling thread only and does not move
|
||||||
|
already-created sockets. So a netns is a spawn/boot-time input
|
||||||
|
alongside `enable_transports`/`tpt_bind_addrs`, and there is
|
||||||
|
deliberately no `await actor.enter_netns(...)` — a mid-life API
|
||||||
|
would silently leave the IPC server bound in the old namespace.
|
||||||
|
Corollary for layer B: pass `netns=` down to `pyroute2` rather
|
||||||
|
than assuming a `trio.to_thread` worker inherits it.
|
||||||
|
|
||||||
|
### `examples/` collection would have failed CI
|
||||||
|
|
||||||
|
`tests/test_docs_examples.py` walks `examples/` recursively and
|
||||||
|
subproc-runs every collected file asserting `rc == 0`. Its filter
|
||||||
|
never checks the extension, so all four `wg_lan` files were
|
||||||
|
collected — including `README.md`, which would have been run as
|
||||||
|
`python README.md`. `'multihost' not in p[0]` was already in the
|
||||||
|
exclusion list with no directory using it. Moving the set under
|
||||||
|
`examples/multihost/wg_lan/` drops collection 24 → 20 with zero
|
||||||
|
test changes; confirmed via `pytest --collect-only`.
|
||||||
|
|
||||||
|
## Corrections applied during the session
|
||||||
|
|
||||||
|
The human corrected two claims that had been asserted without
|
||||||
|
verification, both since retracted in-place in the docs and in the
|
||||||
|
posted issue comments:
|
||||||
|
|
||||||
|
1. that `wg` has "nothing to bind at the tunnel layer, exactly one
|
||||||
|
bind" — wrong; a wg stack is genuinely composed, and the real
|
||||||
|
axis is *who owns* each layer's endpoint.
|
||||||
|
2. that `/ip4/../udp/443/quic-v1` was "also composed" — wrong;
|
||||||
|
that is one endpoint with a protocol qualifier, not a tunnel.
|
||||||
|
|
||||||
|
A self-review before publication also downgraded two
|
||||||
|
overconfident claims to explicitly-flagged assumptions: the
|
||||||
|
`uniffi`-uses-asyncio-only-as-executor thesis (contradicted that
|
||||||
|
plan's own "do not guess from memory" step 0) and TIPC's
|
||||||
|
duplicate-binder round-robin behaviour (unverified).
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: pr475-review-fixes-20260817
|
||||||
|
timestamp: 2026-08-17T23:18:25Z
|
||||||
|
git_ref: 359fe75c
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260817T231825Z_359fe75c_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continue the `/code-review-changes` pass for PR #475 in its isolated
|
||||||
|
worktree. Address the seven accepted manual-review findings in
|
||||||
|
`tractor/ipc/_types.py` and `tractor/ipc/_uds.py`, preserve the existing
|
||||||
|
Windows capability behavior, verify the result, and prepare the work for
|
||||||
|
human-controlled commit and review-reply steps. Do not publish replies,
|
||||||
|
stage, commit, or push without the required explicit authorization.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Restored project quote, docstring, multiline-expression, and
|
||||||
|
`match/case` conventions while retaining the Windows-safe UDS guard.
|
||||||
|
Removed unnecessary structural and comment churn, then verified the
|
||||||
|
focused transport, discovery, and lazy-import paths plus the missing
|
||||||
|
`AF_UNIX` behavior.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/ipc/_types.py` - restore project style and guarded
|
||||||
|
socket-family dispatch.
|
||||||
|
- `tractor/ipc/_uds.py` - format the UDS capability gate
|
||||||
|
consistently.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
None - the generated patch remains uncommitted and awaits human
|
||||||
|
review.
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-17T23:18:25Z
|
||||||
|
git_ref: 359fe75c
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Applied the seven accepted manual-review fixes for PR #475 while
|
||||||
|
preserving the Windows transport capability behavior.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/ipc/_types.py`
|
||||||
|
|
||||||
|
The generated changes restore the project's single-quote docstring and
|
||||||
|
string conventions, remove the unnecessary helper divider, simplify the
|
||||||
|
transport-registry comments, and restore `match/case` socket-family
|
||||||
|
dispatch. The UDS case retains a `HAS_UDS` guard that short-circuits
|
||||||
|
before `socket.AF_UNIX` is evaluated on unsupported hosts. Nearby error
|
||||||
|
messages are wrapped without changing their content.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/ipc/_uds.py`
|
||||||
|
|
||||||
|
The generated change reformats the `HAS_UDS` conjunction according to
|
||||||
|
the project's multiline boolean-expression convention and simplifies
|
||||||
|
the adjacent capability comment.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
`/home/goodboy/repos/tractor/py313/bin/pytest -q tests/test_lazy_imports.py tests/discovery tests/ipc/test_server.py`
|
||||||
|
|
||||||
|
Result: `66 passed, 2 xpassed in 60.62s`.
|
||||||
|
|
||||||
|
`ruff check --no-cache --output-format=json tractor/ipc/_types.py tractor/ipc/_uds.py`
|
||||||
|
|
||||||
|
Result: no findings.
|
||||||
|
|
||||||
|
`git diff --check`
|
||||||
|
|
||||||
|
Result: no whitespace errors.
|
||||||
|
|
||||||
|
An explicit missing-`AF_UNIX` probe set `HAS_UDS = False`, removed the
|
||||||
|
socket constant, and exercised an unsupported socket family. It raised
|
||||||
|
the expected `NotImplementedError` instead of `AttributeError`.
|
||||||
|
|
||||||
|
No review replies, commits, or pushes were published.
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: pr481-review-fixes-p1-20260818
|
||||||
|
timestamp: 2026-08-18T03:15:32Z
|
||||||
|
git_ref: 4151b956
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260818T031532Z_4151b956_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Address the approved review findings on PR #481, but work
|
||||||
|
iteratively: implement and verify one finding at a time, prepare a
|
||||||
|
separate `/commit-plan` after each fix, and stop for the human commit
|
||||||
|
before starting the next finding. Begin with the P1 per-child
|
||||||
|
lifecycle issue. Also publish the already-approved review findings
|
||||||
|
against the reviewed PR head before editing.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Published the approved non-approving review at head `4151b956`, then
|
||||||
|
implemented only the P1 lifecycle fix. Owned one-shot actors now use
|
||||||
|
a child-specific cancellation and process-reap handshake, including
|
||||||
|
hard escalation for unacknowledged cancellation and deterministic
|
||||||
|
bookkeeping removal before `to_actor.run()` returns.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_supervise.py` - coordinate child-specific cancel
|
||||||
|
and reap.
|
||||||
|
- `tractor/spawn/_trio.py` - wait on the Trio child's reap request.
|
||||||
|
- `tractor/spawn/_mp.py` - wait on the multiprocessing child's reap
|
||||||
|
request.
|
||||||
|
- `tractor/spawn/_spawn.py` - publish monitor completion centrally.
|
||||||
|
- `tractor/to_actor/_api.py` - await owned-child process reaping.
|
||||||
|
- `tests/test_to_actor.py` - cover cleanup, escalation, and startup
|
||||||
|
ordering.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
None - the generated P1 patch remains uncommitted for human review.
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-18T03:15:32Z
|
||||||
|
git_ref: 4151b956
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Implemented only the P1 lifecycle finding from the approved PR #481
|
||||||
|
review, preserving the requested one-fix-at-a-time commit boundary.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_supervise.py`
|
||||||
|
|
||||||
|
Added per-child reap request/completion events to `ActorNursery`, a
|
||||||
|
shielded child-specific cancel-and-reap operation, late-registration
|
||||||
|
latching for nursery teardown, and cancellation escalation that waits
|
||||||
|
for debugger release before using non-ignorable process termination.
|
||||||
|
The nursery-wide cancellation path snapshots child records before
|
||||||
|
checkpointing so concurrent one-shot cleanup cannot invalidate its
|
||||||
|
iteration.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_trio.py`
|
||||||
|
|
||||||
|
Changed Trio child monitors to wait on their per-child reap requests.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_mp.py`
|
||||||
|
|
||||||
|
Changed multiprocessing child monitors to wait on their per-child reap
|
||||||
|
requests.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_spawn.py`
|
||||||
|
|
||||||
|
Ensured every backend publishes child-reap completion after its process
|
||||||
|
monitor exits.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/to_actor/_api.py`
|
||||||
|
|
||||||
|
Changed owned one-shot cleanup to await child-specific process joining
|
||||||
|
and bookkeeping removal instead of treating the cancel RPC as reaping.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/test_to_actor.py`
|
||||||
|
|
||||||
|
Added regressions for immediate caller-managed nursery cleanup, failed
|
||||||
|
cancel acknowledgement escalation, and child registration after a
|
||||||
|
latched nursery-wide teardown request.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
`pytest -q tests/test_to_actor.py tests/test_cancellation.py tests/test_spawning.py tests/discovery/test_multi_program.py`
|
||||||
|
|
||||||
|
Result: `46 passed, 1 xfailed, 3 xpassed`.
|
||||||
|
|
||||||
|
`pytest -q tests/test_to_actor.py --tpt-proto uds`
|
||||||
|
|
||||||
|
Result: `13 passed`.
|
||||||
|
|
||||||
|
`pytest -q tests/test_to_actor.py --spawn-backend mp_spawn --tpt-proto tcp`
|
||||||
|
|
||||||
|
Result: `13 passed`.
|
||||||
|
|
||||||
|
One broad verification run was mistakenly launched in parallel with
|
||||||
|
the UDS and `mp_spawn` actor suites. It timed out
|
||||||
|
`test_remote_error_from_caller_nursery`; the node passed immediately
|
||||||
|
in isolation and the complete broad selection then passed serially.
|
||||||
|
The failure was classified as concurrent test-session interference,
|
||||||
|
not accepted as a passing boundary result.
|
||||||
|
|
||||||
|
Ruff, Python compilation, and `git diff --check` passed for the changed
|
||||||
|
boundary. Ruff's existing `_trio.py` F401 finding was reproduced at the
|
||||||
|
unmodified PR head and excluded from attribution to this patch.
|
||||||
|
|
||||||
|
No source files were staged, committed, pushed, or used for review
|
||||||
|
replies. The previously approved top-level review was published before
|
||||||
|
the fix at reviewed head `4151b956`.
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_3e4c90d3eafeqHEtRYSIHgHhpA
|
||||||
|
timestamp: 2026-08-18T19:30:01Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260818T193001Z_bf06b4f8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Fix cancellation during an indeterminate transport write without
|
||||||
|
allowing a second RPC msg to corrupt the stream. Keep the transport
|
||||||
|
fix separate from the higher-level `to_actor` remediation and explain
|
||||||
|
the length-prefixed protocol rather than using unexplained "framed
|
||||||
|
send" terminology.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Close a msgpack transport stream when cancellation interrupts its
|
||||||
|
length-prefixed `send_all()` operation. The stream can no longer be
|
||||||
|
safely reused because Trio cannot report how many bytes were written.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/ipc/_transport.py` - close an interrupted send stream.
|
||||||
|
- `tests/ipc/test_each_tpt.py` - cover cancellation during the write.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human required this transport edge-case fix to land as its own
|
||||||
|
behavioral commit with a detailed message. During staged review, the
|
||||||
|
human also rejected the unexplained "framed send" wording and asked
|
||||||
|
for terminology tied directly to the actual transport operation.
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-18T19:30:01Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Prospective review found that cancellation can interrupt
|
||||||
|
`MsgpackTransport.send()` after `send_all()` writes only part of its
|
||||||
|
length-prefixed msg. Sending a cancellation request afterward can
|
||||||
|
append another msg to the indeterminate stream and desynchronize the
|
||||||
|
peer decoder.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/ipc/_transport.py tests/ipc/test_each_tpt.py`
|
||||||
|
|
||||||
|
Close the stream under a cancellation shield when `send_all()` is
|
||||||
|
cancelled. Cover the behavior with a fake stream that checkpoints
|
||||||
|
inside the write and records forced closure.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_3e4c90d3eafeqHEtRYSIHgHhpA
|
||||||
|
timestamp: 2026-08-18T19:30:02Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260818T193002Z_bf06b4f8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Distill repeated `Actor._contexts.pop()` machinery into a wrapper like
|
||||||
|
the RPC-task registration helper so future teardown sites do not keep
|
||||||
|
reconstructing the context-registry key independently. Preserve the
|
||||||
|
existing lifecycle-specific cleanup behavior.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Add idempotent `Actor._drop_context()` registry removal keyed from the
|
||||||
|
context's own channel and CID. Use it for caller context teardown and
|
||||||
|
the strict callee-side RPC deregistration path.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_runtime.py` - own context-registry removal.
|
||||||
|
- `tractor/runtime/_rpc.py` - use the helper for callee teardown.
|
||||||
|
- `tractor/_context.py` - use the helper after caller teardown.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human identified the repeated registry-pop code and requested a
|
||||||
|
central primitive analogous to `_register_rpc_task()`. The agent first
|
||||||
|
suggested an async helper that also closed receive channels; the final
|
||||||
|
design was narrowed to registry removal only so each lifecycle owner
|
||||||
|
retains its existing closure, debugger, shielding, and error policy.
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-18T19:30:02Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Repeated teardown sites reconstruct the `Actor._contexts` registry
|
||||||
|
key from a portal channel and context ID before popping it. Add an
|
||||||
|
idempotent actor-owned helper deriving the key from the context itself,
|
||||||
|
then route caller and callee context teardown through that helper.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_runtime.py tractor/runtime/_rpc.py tractor/_context.py`
|
||||||
|
|
||||||
|
Keep receive-channel closure and cancellation shielding in each
|
||||||
|
lifecycle owner so the helper centralizes registry machinery without
|
||||||
|
changing their teardown ordering.
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_3e4c90d3eafeqHEtRYSIHgHhpA
|
||||||
|
timestamp: 2026-08-18T19:30:03Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260818T193003Z_bf06b4f8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Cancel a remote task when its caller is cancelled after `Start`
|
||||||
|
publication but before startup acknowledgement. Keep cancellation
|
||||||
|
bounded, prevent its private `_cancel_task` RPC from recursively
|
||||||
|
cancelling itself and preserve public target kwargs unchanged.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Add private portal startup policy, use it for non-recursive context
|
||||||
|
cancellation and clean caller-side startup state under a shield.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_portal.py` - separate private startup policy.
|
||||||
|
- `tractor/_context.py` - disable recursion for cancellation RPCs.
|
||||||
|
- `tractor/runtime/_runtime.py` - clean cancelled task startup.
|
||||||
|
- `tests/test_context_stream_semantics.py` - control cancellation
|
||||||
|
between `Start` publication and acknowledgement.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human required this cancellation behavior to remain a distinct
|
||||||
|
commit from general startup failures and from the public `to_actor`
|
||||||
|
API. The human also requested that its runtime comment describe the
|
||||||
|
actual length-prefixed transport guarantee and concrete `_cancel_task`
|
||||||
|
operation rather than referring to an unnamed wrapper.
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-18T19:30:03Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Cancellation while `Actor.start_remote_task()` waits for `StartAck`
|
||||||
|
can strand its caller-side context and leave the remote task running.
|
||||||
|
Make one bounded cleanup request, remove local startup state and close
|
||||||
|
its receive channel.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_runtime.py tractor/runtime/_portal.py tractor/_context.py tests/test_context_stream_semantics.py`
|
||||||
|
|
||||||
|
Separate private startup-cancellation policy from public target kwargs
|
||||||
|
using `Portal._run_from_ns()`. Have `Context.cancel()` disable recursive
|
||||||
|
startup cancellation for its own `_cancel_task` RPC. Exercise
|
||||||
|
cancellation after `Start` publication and prove the caller-owned actor
|
||||||
|
remains reusable without leaked contexts.
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_3e4c90d3eafeqHEtRYSIHgHhpA
|
||||||
|
timestamp: 2026-08-18T19:30:04Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260818T193004Z_bf06b4f8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Release caller-side context state for every remote-task startup failure,
|
||||||
|
not only local cancellation. Preserve the remote error, avoid unsafe
|
||||||
|
follow-up sends and prove pre-publication serialization failures leave
|
||||||
|
a reused portal healthy.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Extend remote-task startup cleanup across send, acknowledgement and
|
||||||
|
validation errors. Track completed publication, perform only safe
|
||||||
|
best-effort cancellation and deterministically remove local state.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_runtime.py` - clean every startup failure path.
|
||||||
|
- `tests/test_context_stream_semantics.py` - cover authorization and
|
||||||
|
serialization failures before context entry.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human accepted the discovered edge-case fixes but required general
|
||||||
|
startup cleanup to land separately from cancellation cleanup, transport
|
||||||
|
integrity and the public API. This boundary preserves that behavioral
|
||||||
|
distinction and its dedicated commit-message rationale.
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-18T19:30:04Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
`Actor.start_remote_task()` inserts a context before sending `Start`,
|
||||||
|
but startup errors other than cancellation escape without removing or
|
||||||
|
closing that caller state. Serialization errors, acknowledgement
|
||||||
|
timeouts, malformed acknowledgements and remote authorization errors
|
||||||
|
can therefore leak context-registry entries.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_runtime.py tests/test_context_stream_semantics.py`
|
||||||
|
|
||||||
|
Cover the complete send, acknowledgement and validation phase with
|
||||||
|
exceptional cleanup. Attempt remote cancellation only when publication
|
||||||
|
is known complete or protocol-safe, and always release local state.
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_3e4c90d3eafeqHEtRYSIHgHhpA
|
||||||
|
timestamp: 2026-08-18T19:30:05Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260818T193005Z_bf06b4f8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Replace abandoned `Portal.run()` one-shots with a static linked-context
|
||||||
|
endpoint. Follow Trio positional-call semantics, use partials for target
|
||||||
|
keywords, preserve Python 3.14 Placeholder behavior, keep target lookup
|
||||||
|
behind the RPC allowlist and support private, nursery and portal
|
||||||
|
placement.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Use `Portal.open_context()` and `Context.wait_for_result()` for one-shot
|
||||||
|
tasks. Normalize every partial layer, validate signatures locally and
|
||||||
|
send target namespace/function components separately to the authorized
|
||||||
|
remote resolver. Retain the client-side function in its `NamespacePath`
|
||||||
|
so `to_tuple()` does not re-import it. Owned actors enable the declaring
|
||||||
|
`_api.__name__` directly; caller-owned portals opt in through the public
|
||||||
|
`to_actor.MODULE` alias.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/to_actor/_api.py` - implement linked one-shot calls.
|
||||||
|
- `tractor/to_actor/__init__.py` - export `MODULE`.
|
||||||
|
- `tractor/msg/ptr.py` - retain refs created by `from_ref()`.
|
||||||
|
- `tests/test_to_actor.py` - cover the public API and authorization.
|
||||||
|
- `examples/parallelism/to_actor_one_shots.py` - use positional inputs.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human rejected nested target-kwargs configuration and selected
|
||||||
|
Trio-style positional inputs plus `functools.partial()`. During staged
|
||||||
|
review the human required a Python 3.14 compatibility comment rather
|
||||||
|
than removing Placeholder support, requested separate namespace and
|
||||||
|
function inputs, preserved `_get_rpc_func(ns: str, funcname: str)`
|
||||||
|
authorization, renamed `RPC_MODULE` to `MODULE`, rejected global module
|
||||||
|
exposure and deferred speculative nursery/module-list helpers to the
|
||||||
|
`open_taskman()` design line. The human also required this public API
|
||||||
|
to land only after its lower-level safety dependencies. In final staged
|
||||||
|
review, the human required `_invoke_from_portal()` to use
|
||||||
|
`NamespacePath.to_tuple()` with the already-held function ref and
|
||||||
|
required internal actor setup to use `_api.__name__` directly, keeping
|
||||||
|
`to_actor.MODULE` solely as the public importer-facing alias.
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-18T19:30:05Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement `to_actor.run()` with Trio-style positional target arguments,
|
||||||
|
`functools.partial` keyword and Python 3.14 Placeholder binding, and a
|
||||||
|
static context endpoint that links remote results, errors and caller
|
||||||
|
cancellation.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/to_actor/_api.py tractor/to_actor/__init__.py`
|
||||||
|
|
||||||
|
Resolve target functions through `Actor._get_rpc_func()` so module
|
||||||
|
authorization remains authoritative. Automatically expose the helper
|
||||||
|
module for actors owned by `to_actor.run()` and document explicit
|
||||||
|
exposure for a caller-owned portal.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/test_to_actor.py examples/parallelism/to_actor_one_shots.py`
|
||||||
|
|
||||||
|
Cover placement modes, argument binding, nested partials, caller-linked
|
||||||
|
cancellation, remote errors and module authorization.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_3e4c90d3eafeqHEtRYSIHgHhpA
|
||||||
|
timestamp: 2026-08-19T02:07:57Z
|
||||||
|
git_ref: b38efed7
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260819T020757Z_b38efed7_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Resolve the remaining P3 review finding before landing PR #481:
|
||||||
|
`runtime_kwargs={}` must not be silently accepted alongside either
|
||||||
|
`an=` or `portal=` merely because the dict is falsey. Keep this as its
|
||||||
|
own final review-remediation commit.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Treat any non-`None` `runtime_kwargs` value as provided when validating
|
||||||
|
placement. Cover both placement APIs with empty and configured dicts,
|
||||||
|
proving the error is raised locally before actor startup.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/to_actor/_api.py` - validate option presence explicitly.
|
||||||
|
- `tests/test_to_actor.py` - cover four invalid option combinations.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
No direct line edits. The human accepted the P3 finding, required it to
|
||||||
|
remain separate from the five P2 behavioral commits and prioritized it
|
||||||
|
before the final PR #484 integration rebase and PR #481 landing steps.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-19T02:07:57Z
|
||||||
|
git_ref: b38efed7
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix the final PR #481 review finding: `runtime_kwargs` is mutually
|
||||||
|
exclusive with both caller placement options whenever it is provided,
|
||||||
|
including an empty dict.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/to_actor/_api.py tests/test_to_actor.py`
|
||||||
|
|
||||||
|
Use an explicit `is not None` check rather than dict truthiness. Expand
|
||||||
|
the validation regression across `an=` and `portal=`, each with empty
|
||||||
|
and configured runtime kwargs, so every invalid combination fails
|
||||||
|
before actor runtime startup.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Trio/TCP: `23 passed`
|
||||||
|
- Trio/UDS: `23 passed`
|
||||||
|
- `mp_spawn`/TCP: `23 passed`
|
||||||
|
- Ruff and `git diff --check`: clean
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
|
||||||
|
timestamp: 2026-08-19T18:46:40Z
|
||||||
|
git_ref: 481ba003
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260819T184640Z_481ba003_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Rebase PR #484 onto final PR #481, migrate every affected one-shot call
|
||||||
|
to the new positional target API and continue through downstream tests,
|
||||||
|
examples and documentation review.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Converted stale target keyword calls to target partials so previously
|
||||||
|
named inputs remain explicit while placement/runtime controls stay
|
||||||
|
direct. Updated error expectations for local signature validation and
|
||||||
|
linked remote error propagation, then corrected docs which still
|
||||||
|
described the removed one-shot implementation. Linked spawning and
|
||||||
|
context lifecycle prose to the corresponding API methods and detailed
|
||||||
|
context guide.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `docs/api/core.rst` - describe linked one-shot context execution.
|
||||||
|
- `docs/guide/rpc.rst` - update placement and target call semantics.
|
||||||
|
- `docs/guide/spawning.rst` - document positional target inputs.
|
||||||
|
- `examples/debugging/multi_nested_subactors_error_up_through_nurseries.py` - migrate nested actor target inputs.
|
||||||
|
- `examples/debugging/root_cancelled_but_child_is_in_tty_lock.py` - preserve named recursive target inputs with partials.
|
||||||
|
- `tests/test_advanced_streaming.py` - migrate streaming target inputs.
|
||||||
|
- `tests/test_cancellation.py` - migrate calls and tighten errors.
|
||||||
|
- `tests/test_infected_asyncio.py` - bind asyncio target options.
|
||||||
|
- `tests/test_rpc.py` - migrate RPC target argument binding.
|
||||||
|
- `tests/test_runtime.py` - preserve named runtime target inputs.
|
||||||
|
- `tests/test_spawning.py` - preserve named spawning target inputs.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected the stack order and final PR #481 base, asked the
|
||||||
|
agent to continue after each diagnostic step and required a complete
|
||||||
|
commit plan after independently force-pushing the rebased history.
|
||||||
|
After reviewing the migration, the human required every formerly named
|
||||||
|
target input to remain visibly named through `functools.partial()`
|
||||||
|
rather than becoming positional. These were human-directed agent edits;
|
||||||
|
the human also required plain `start_actor()` and `open_context()`
|
||||||
|
references in the spawning and RPC guides to link to their API methods
|
||||||
|
and the detailed context guide, then clarified that `to_actor.run()`
|
||||||
|
already uses the full context API while `Portal.run()` should share
|
||||||
|
linked lifecycle machinery without necessarily delegating through
|
||||||
|
`Portal.open_context()` or adding a `Started` message. The human made
|
||||||
|
no direct source-line edits.
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-19T18:46:40Z
|
||||||
|
git_ref: 481ba003
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Migrate PR #484's downstream one-shot calls to PR #481's final
|
||||||
|
`tractor.to_actor.run()` contract after the stack rebase.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- docs examples tests`
|
||||||
|
|
||||||
|
Pass target arguments positionally and bind target keyword-only inputs
|
||||||
|
with `functools.partial()`. Keep placement and runtime controls as
|
||||||
|
direct `to_actor.run()` keywords. Update the invalid-target-argument
|
||||||
|
test to expect local signature binding before actor startup and require
|
||||||
|
direct `RemoteActorError` propagation from linked one-shots.
|
||||||
|
|
||||||
|
Update API and guide prose to describe positional target inputs,
|
||||||
|
linked `Portal.open_context()` execution and per-child reaping instead
|
||||||
|
of the removed `Portal.run()` and target-`**kwargs` conventions.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- core and migrated runtime batches: `97 passed`
|
||||||
|
- discovery and related lifecycle batch: `33 passed, 1 skipped`
|
||||||
|
- changed executable examples: `9 passed`
|
||||||
|
- mapped debugger cases: `12 passed, 6 skipped`
|
||||||
|
- Ruff, compilation and `git diff --check`: clean
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
|
||||||
|
timestamp: 2026-08-19T23:48:23Z
|
||||||
|
git_ref: 557065d8
|
||||||
|
scope: tests
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260819T234823Z_557065d8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Investigate PR #481's red CI run, explain the missing T-800 and
|
||||||
|
debugger-output failures, and proceed with fixes in the PR #481
|
||||||
|
worktree.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Updated stale teardown assertions to match #481's direct hard-reap
|
||||||
|
path and observable process-lifetime invariants. Made nested debugger
|
||||||
|
checks consume the complete pexpect transcript rather than only the
|
||||||
|
last prompt latch.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tests/devx/test_debugger.py` - assert EOF/dead-process teardown and
|
||||||
|
accumulate nested debugger output across prompt boundaries.
|
||||||
|
- `tests/devx/test_tooling.py` - assert cancel-timeout hard-reap
|
||||||
|
escalation instead of the bypassed T-800 backend marker.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human reported the still-red PR #481 CI, supplied a failing job URL,
|
||||||
|
required work in `/wkts/pr481_review_fixes` and directed the agent to
|
||||||
|
continue immediately. No direct source-line edits were made by the
|
||||||
|
human.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-19T23:48:23Z
|
||||||
|
git_ref: 557065d8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Diagnose and fix the stale debugger and reaper assertions failing PR
|
||||||
|
#481's Unix CI jobs.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/devx/test_debugger.py tests/devx/test_tooling.py`
|
||||||
|
|
||||||
|
Replace the old T-800 backend-log requirement with the new bounded
|
||||||
|
cancel-ack escalation evidence. Prove debugger teardown with EOF and a
|
||||||
|
dead child process instead of requiring optional `KeyboardInterrupt`
|
||||||
|
text. Accumulate all pexpect prompt chunks for nested error propagation
|
||||||
|
so expected tracebacks are not lost when `child.before` advances.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- exact failed debugger/reaper nodes: `4 passed`
|
||||||
|
- debugger/tooling TCP: `39 passed, 6 skipped`
|
||||||
|
- debugger/tooling UDS: `39 passed, 6 skipped`
|
||||||
|
- full TCP suite: `478 passed, 9 skipped, 7 xfailed, 3 xpassed`
|
||||||
|
- full UDS rerun: `476 passed, 11 skipped, 8 xfailed, 2 xpassed`
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
|
||||||
|
timestamp: 2026-08-19T23:48:24Z
|
||||||
|
git_ref: 557065d8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260819T234824Z_557065d8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Investigate and fix PR #481's macOS TCP clustering and stream-overrun
|
||||||
|
failures without sacrificing IPC frame integrity or structured
|
||||||
|
concurrency.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Changed cancellation during `send_all()` from actor-wide stream closure
|
||||||
|
to shielded complete-frame publication followed by immediate pending
|
||||||
|
cancellation. Prevented failed overrun error shipment from promoting a
|
||||||
|
secondary transport closure over the context-local primary condition.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/ipc/_transport.py` - complete in-flight frames before
|
||||||
|
delivering sender cancellation.
|
||||||
|
- `tractor/_context.py` - absorb transport closure while reporting an
|
||||||
|
overrun on an already-closing channel.
|
||||||
|
- `tests/ipc/test_each_tpt.py` - prove complete framing, cancellation
|
||||||
|
delivery and channel reuse.
|
||||||
|
- `tests/test_context_stream_semantics.py` - prove overrun reporting
|
||||||
|
tolerates a closed transport.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human reported PR #481's red CI, asked for diagnosis and directed
|
||||||
|
the agent to proceed in the dedicated PR #481 worktree. During final
|
||||||
|
review, the human required preservation of the original far-end
|
||||||
|
cancellation rationale and fuller documentation of frame shielding,
|
||||||
|
shared-channel ownership and cancellation-delay tradeoffs. These were
|
||||||
|
human-directed agent edits; the human made no direct source-line edits.
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-19T23:48:24Z
|
||||||
|
git_ref: 557065d8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix the macOS TCP regressions where cancellation during a framed send
|
||||||
|
closed the actor-wide channel and replaced primary stream errors with
|
||||||
|
secondary `TransportClosed` failures.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/ipc/_transport.py tractor/_context.py tests/ipc/test_each_tpt.py tests/test_context_stream_semantics.py`
|
||||||
|
|
||||||
|
Shield complete frame publication, then deliver pending cancellation
|
||||||
|
immediately after leaving the shield. Preserve channel reuse instead of
|
||||||
|
closing the multiplexed socket from a context-local sender. Treat
|
||||||
|
`TransportClosed` while shipping `StreamOverrun` as failed delivery so
|
||||||
|
the secondary error can not crash the actor-wide RPC loop.
|
||||||
|
|
||||||
|
Add deterministic unit regressions for cancellation in the middle of a
|
||||||
|
frame and overrun reporting after transport closure.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- transport/context unit regressions: `3 passed`
|
||||||
|
- exact TCP and UDS CI-node batches: `11 passed, 1 skipped`
|
||||||
|
- transport/context/clustering/RPC TCP: `88 passed`
|
||||||
|
- transport/context/clustering/RPC UDS: `86 passed, 2 skipped`
|
||||||
|
- full TCP suite: `478 passed, 9 skipped, 7 xfailed, 3 xpassed`
|
||||||
|
- full UDS rerun: `476 passed, 11 skipped, 8 xfailed, 2 xpassed`
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
|
||||||
|
timestamp: 2026-08-20T02:30:04Z
|
||||||
|
git_ref: 88a23449
|
||||||
|
scope: tests
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260820T023004Z_88a23449_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Inspect the two failed macOS jobs in PR #481's new CI run and continue
|
||||||
|
toward a green landing candidate.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Confirmed both jobs fail only the known nested crash-REPL scenario from
|
||||||
|
issue #320, while Ubuntu TCP/UDS and Windows pass. Added a targeted
|
||||||
|
macOS-CI skip without reducing Linux coverage.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tests/devx/test_debugger.py` - skip the issue #320 nested
|
||||||
|
crash-REPL node on Darwin CI.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human monitored the new CI run, reported both macOS jobs dead and
|
||||||
|
directed the agent to continue diagnosis. No direct source-line edits
|
||||||
|
were made by the human.
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-20T02:30:04Z
|
||||||
|
git_ref: 88a23449
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Diagnose the remaining macOS PR #481 CI failures after the Linux
|
||||||
|
debugger and transport fixes passed.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/devx/test_debugger.py`
|
||||||
|
|
||||||
|
Both macOS transports failed the same deeply nested crash-REPL test
|
||||||
|
already tracked by issue #320: TCP omitted one actor-specific traceback
|
||||||
|
record and UDS timed out waiting for a nested prompt. Apply an explicit
|
||||||
|
Darwin-CI skip to this one node while retaining Linux TCP/UDS coverage.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- debugger/tooling TCP: `39 passed, 6 skipped`
|
||||||
|
- debugger/tooling UDS: `39 passed, 6 skipped`
|
||||||
|
- Ruff, compilation and `git diff --check`: clean
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
|
||||||
|
timestamp: 2026-08-20T02:30:05Z
|
||||||
|
git_ref: 88a23449
|
||||||
|
scope: docs
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260820T023005Z_88a23449_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Audit all documentation and executable examples once more, replacing
|
||||||
|
prescriptive `run_in_actor()` usage with `to_actor.run()` or explicit
|
||||||
|
actor/context lifetime APIs before PR #481 lands.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Rewrote one-shot documentation around direct blocking result delivery,
|
||||||
|
linked context execution and per-call reaping. Migrated all runnable
|
||||||
|
examples, using daemon actors where reciprocal dialogs require longer
|
||||||
|
lifetimes. Added API/guide cross-links and retained only three explicit
|
||||||
|
legacy references.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `docs/` - update API, quickstart and subsystem guides to showcase
|
||||||
|
`tractor.to_actor.run()` and link its underlying core APIs.
|
||||||
|
- `examples/` - migrate one-shot calls and preserve explicit daemon
|
||||||
|
lifetimes for reciprocal or long-lived actor dialogs.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human requested a final docs pass covering every place that should
|
||||||
|
showcase `to_actor` over `.run_in_actor()`. Earlier review also required
|
||||||
|
named target arguments to remain visible through `functools.partial()`
|
||||||
|
and core API references to link to local guides/reference pages. These
|
||||||
|
were human-directed agent edits; the human made no direct source-line
|
||||||
|
edits.
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-20T02:30:05Z
|
||||||
|
git_ref: 88a23449
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Perform a final rendered-documentation and executable-example pass so
|
||||||
|
PR #481 showcases `tractor.to_actor.run()` instead of the legacy
|
||||||
|
`ActorNursery.run_in_actor()` API.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- docs examples`
|
||||||
|
|
||||||
|
Migrate one-shot guides and examples to direct result delivery through
|
||||||
|
`to_actor.run()`, preserving named target inputs with target partials.
|
||||||
|
Use daemon actors and concurrent portal calls where reciprocal actor
|
||||||
|
lifetimes require both peers to coexist. Add API and guide cross-links,
|
||||||
|
and retain only explicit legacy/removal notes.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- executable docs examples: `23 passed`
|
||||||
|
- debugger/tooling TCP: `39 passed, 6 skipped`
|
||||||
|
- debugger/tooling UDS: `39 passed, 6 skipped`
|
||||||
|
- Ruff, compilation and `git diff --check`: clean
|
||||||
|
- local Sphinx build unavailable because Sphinx is not installed
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
|
||||||
|
timestamp: 2026-08-20T13:51:25Z
|
||||||
|
git_ref: 9f99043b
|
||||||
|
scope: tests
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260820T135125Z_9f99043b_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continue preparing PR #481 for landing after the prior test and
|
||||||
|
documentation commits were pushed. Follow CI and proceed with clear next
|
||||||
|
steps without merging or changing remote content unasked.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Followed CI through completion and found both macOS jobs failed because the
|
||||||
|
new `skipif` expression returned the `CI=true` environment string. Corrected
|
||||||
|
the condition to pass pytest a boolean before evaluating the marker. A
|
||||||
|
simulated Darwin-CI run now skips cleanly, and the sequential TCP and UDS
|
||||||
|
debugger/tooling suites each pass with 39 passed and 6 skipped.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tests/devx/test_debugger.py` - coerce the Darwin-CI skip condition to a
|
||||||
|
boolean.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human pushed the preceding commits, directed the agent to continue, and
|
||||||
|
approved recording this test-only follow-up in Prompt-IO. No direct
|
||||||
|
source-line edits were made by the human.
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-20T13:51:25Z
|
||||||
|
git_ref: 9f99043b
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Continue preparing PR #481 for landing after the test and documentation
|
||||||
|
commits were pushed. Follow the new CI run to completion and diagnose any
|
||||||
|
failures.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/devx/test_debugger.py`
|
||||||
|
|
||||||
|
Both macOS jobs failed while evaluating the new `skipif` marker. The
|
||||||
|
expression returned the `CI=true` environment string instead of a boolean,
|
||||||
|
so pytest evaluated `true` as Python source and raised `NameError` during
|
||||||
|
test setup. Coerce `_ci_env` to `bool` so pytest receives a boolean marker
|
||||||
|
condition on Darwin CI.
|
||||||
|
|
||||||
|
Verification should exercise the condition with `CI=true` and a simulated
|
||||||
|
Darwin platform, then rerun the debugger/tooling TCP and UDS suites.
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
|
||||||
|
timestamp: 2026-08-20T14:38:50Z
|
||||||
|
git_ref: 559fd0f1
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260820T143845Z_559fd0f1_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continue preparing PR #481 after the latest fix was pushed. Follow CI and
|
||||||
|
proceed with clear next steps toward a green landing candidate.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Traced the remaining macOS UDS failure to cancellation racing transport
|
||||||
|
teardown inside the shielded framed-send path. Preserve pending cancellation
|
||||||
|
over a transport error caused by concurrent teardown, and add a deterministic
|
||||||
|
regression for that ordering. A follow-up A/B run showed the corrected
|
||||||
|
cancellation precedence changes which nested debugger intermediary is
|
||||||
|
rendered as the immediate source versus relay, so retain coverage for both
|
||||||
|
actor levels without pinning those racy roles. The adjusted UDS node passes
|
||||||
|
three consecutive runs, and both debugger/tooling transport suites pass with
|
||||||
|
39 passed and 6 skipped.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/ipc/_transport.py` - deliver pending cancellation before
|
||||||
|
translating a shielded send's transport error.
|
||||||
|
- `tests/ipc/test_each_tpt.py` - reproduce cancellation followed by local
|
||||||
|
stream closure during shielded frame publication.
|
||||||
|
- `tests/devx/test_debugger.py` - accept either valid source/relay role for
|
||||||
|
each nested intermediary while retaining the actor and error assertions.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human pushed the preceding fix, ran the proposed verification plan, and
|
||||||
|
reported a repeated UDS debugger failure. That report prompted the A/B
|
||||||
|
comparison and role-insensitive assertion. No direct source-line edits were
|
||||||
|
made by the human.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-20T14:38:50Z
|
||||||
|
git_ref: 559fd0f1
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Continue preparing PR #481 after pushing the macOS debugger skip fix.
|
||||||
|
Follow the replacement CI run and address any remaining PR-specific
|
||||||
|
failure.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/ipc/_transport.py`
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/ipc/test_each_tpt.py`
|
||||||
|
|
||||||
|
macOS UDS failed `test_reqresp_ontopof_streaming` when its two-second
|
||||||
|
`move_on_after()` scope cancelled during `stream.send('ping')`. Commit
|
||||||
|
`88a23449` shields framed `send_all()` and checks pending cancellation only
|
||||||
|
after a successful write. Concurrent transport teardown instead closed the
|
||||||
|
socket, causing `ClosedResourceError` to escape as `TransportClosed` before
|
||||||
|
the pending cancellation could be delivered.
|
||||||
|
|
||||||
|
Preserve structured cancellation precedence on the shielded send's
|
||||||
|
transport-error path, and add a deterministic regression that cancels the
|
||||||
|
sender before making the fake stream raise `ClosedResourceError`.
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: 7b9c97c4-fff7-4ac4-97fb-35720453308e
|
||||||
|
timestamp: 2026-08-20T15:02:50Z
|
||||||
|
git_ref: pformat_caller_frame_render_guard
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260820T150250Z_9afda1c6_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Fix both newly exposed send-side `MsgTypeError` formatting failures
|
||||||
|
and pin them with an end-to-end regression in PR #503.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Corrected codec-spec formatting and default error-message assembly so
|
||||||
|
`_mk_send_mte()` returns a printable error instead of raising another
|
||||||
|
formatter exception.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/msg/_codec.py` - pass the codec to its supported formatter.
|
||||||
|
- `tractor/_exceptions.py` - assemble the default message as `str`.
|
||||||
|
- `tests/devx/test_pformat.py` - render the complete default error.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected both one-line fixes and the single end-to-end test
|
||||||
|
as coherent additions to PR #503, while leaving broader formatter
|
||||||
|
cleanup out of scope.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-20T15:02:50Z
|
||||||
|
git_ref: pformat_caller_frame_render_guard
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After reviewing additional `tractor.devx.pformat` work suitable for
|
||||||
|
PR #503, the user approved fixing both send-side `MsgTypeError`
|
||||||
|
formatting failures and adding an end-to-end regression.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
The generated code corrects the `MsgCodec.msg_spec_str` formatter
|
||||||
|
input, keeps `_mk_send_mte()`'s assembled default message a string,
|
||||||
|
and tests that the resulting `MsgTypeError` can be rendered:
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/msg/_codec.py tractor/_exceptions.py tests/devx/test_pformat.py`
|
||||||
|
|
||||||
|
These failures were hidden behind the original
|
||||||
|
`pformat_caller_frame()` keyword error addressed by the first two
|
||||||
|
commits on the branch.
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
|
||||||
|
timestamp: 2026-08-21T02:35:37Z
|
||||||
|
git_ref: ae6f2ac3
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260821T023537Z_ae6f2ac3_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Simplify bounded actor cancellation by passing an explicit absolute
|
||||||
|
deadline from `Portal.cancel_actor()` through `_run_from_ns()`,
|
||||||
|
`Actor.start_remote_task()`, and `Channel.send()` into
|
||||||
|
`MsgpackTransport.send()`. Avoid a `ContextVar`, watcher tasks, shared
|
||||||
|
status, coalescing, and waiter state. After tracing the current
|
||||||
|
`Start -> StartAck -> CancelAck` transaction, rename the local result to
|
||||||
|
`cancel_ack_received`, document its exact semantics, and link a focused
|
||||||
|
follow-up for a dedicated `Cancel -> CancelAck` protocol.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Threaded one absolute Trio deadline through the existing private
|
||||||
|
actor-cancel RPC path. The transport retains complete-frame shielding
|
||||||
|
for ordinary sends, while a cancel-control send that overruns its
|
||||||
|
deadline force-closes the potentially corrupted stream before releasing
|
||||||
|
the send lock. The outer actor-cancel scope uses the same deadline for
|
||||||
|
ack waiting and redelivers pending caller cancellation afterward.
|
||||||
|
|
||||||
|
Renamed the completion flag to `cancel_ack_received` and documented that
|
||||||
|
the current private call consumes `StartAck`, then receives a real
|
||||||
|
`CancelAck` after `Actor.cancel()` completes; this does not establish
|
||||||
|
that the OS process exited. Added a source TODO linking issue #506 for
|
||||||
|
the future first-class `Cancel -> CancelAck` transaction.
|
||||||
|
|
||||||
|
Focused transport and actor-cancel verification passed all four tests.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_portal.py` - own the absolute deadline, accurately
|
||||||
|
record ack receipt, and link the dedicated cancellation protocol.
|
||||||
|
- `tractor/runtime/_runtime.py` - forward the optional deadline for the
|
||||||
|
exact private `Start` publication.
|
||||||
|
- `tractor/ipc/_chan.py` - pass the operation-specific deadline to the
|
||||||
|
transport without changing ordinary sends.
|
||||||
|
- `tractor/ipc/_transport.py` - bound the shielded frame publication and
|
||||||
|
close a partial-frame stream before unlocking it.
|
||||||
|
- `tests/ipc/test_each_tpt.py` - cover deadline expiry after a partial
|
||||||
|
frame prefix reaches the stream.
|
||||||
|
- `tests/test_to_actor.py` - prove actor-cancel publication and ack
|
||||||
|
waiting share one absolute timeout budget.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human rejected the initial watcher-task, shared `_SendStatus`, cancel
|
||||||
|
coalescing, and per-waiter design as unnecessary complexity. They also
|
||||||
|
rejected `ContextVar` propagation in favor of explicit functional
|
||||||
|
threading, selected a single absolute deadline for publication and ack
|
||||||
|
waiting, and required item 2 to remain separate from the item-3 child
|
||||||
|
reaping work. After reviewing the result, they requested the precise
|
||||||
|
`cancel_ack_received` name, a detailed protocol-trace comment, a focused
|
||||||
|
follow-up issue, and a linked source TODO. No direct source-line edits
|
||||||
|
were made by the human.
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-21T02:35:37Z
|
||||||
|
git_ref: ae6f2ac3
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Replace the actor-cancel timeout watcher/status experiment with one
|
||||||
|
explicit absolute deadline threaded through the existing private call
|
||||||
|
path. Do not use a `ContextVar`, shared result state, waiter
|
||||||
|
coalescing, or polling tasks.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_portal.py`
|
||||||
|
|
||||||
|
`Portal.cancel_actor()` computes one absolute deadline and uses it for
|
||||||
|
both `Start` frame publication and the subsequent cancel-ack wait.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_runtime.py`
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/ipc/_chan.py`
|
||||||
|
|
||||||
|
The private RPC path forwards the operation-specific deadline. Lower
|
||||||
|
layers preserve the ordinary infinite-deadline call shape.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/ipc/_transport.py`
|
||||||
|
|
||||||
|
`MsgpackTransport.send()` applies the deadline inside its complete-frame
|
||||||
|
shield. If the deadline expires after partial publication, it closes
|
||||||
|
the unusable stream before releasing the send lock.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/ipc/test_each_tpt.py`
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/test_to_actor.py`
|
||||||
|
|
||||||
|
Focused regressions prove a partial-frame timeout closes the stream and
|
||||||
|
that actor-cancel publication and acknowledgement share one budget.
|
||||||
|
|
||||||
|
The implementation removes the earlier `_SendStatus`, watcher task,
|
||||||
|
coalescing, shared cancel result, and per-waiter state. Four focused
|
||||||
|
transport and actor-cancel tests pass.
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
|
||||||
|
timestamp: 2026-08-21T04:08:03Z
|
||||||
|
git_ref: 3c1bbe73
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260821T040803Z_3c1bbe73_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continue PR #481 review cleanup one item at a time. Compare the
|
||||||
|
remaining child-registration/reaping experiment against PR #484,
|
||||||
|
identify the next valid behavior, and generate an exact commit plan for
|
||||||
|
that boundary without committing automatically.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Separated late-child admission from the broader bounded-reap rewrite.
|
||||||
|
Added one synchronous `ActorNursery._register_child()` publication step
|
||||||
|
that stores the child and its reap events before sampling nursery
|
||||||
|
cancellation. This closes both scheduler orderings around
|
||||||
|
`ActorNursery.cancel()`'s child snapshot.
|
||||||
|
|
||||||
|
The MP backend registers immediately before synchronous process startup
|
||||||
|
and refuses to start when cancellation already owns the child. The Trio
|
||||||
|
backend registers immediately after `open_process()` and kills that
|
||||||
|
already-created process when registration observes cancellation. An
|
||||||
|
early `start_actor()` guard rejects calls begun after cancellation is
|
||||||
|
already visible.
|
||||||
|
|
||||||
|
Deterministic tests cover the nursery registration ordering and the MP
|
||||||
|
no-start invariant. Comparison with PR #484 confirmed that its retained
|
||||||
|
generic nursery/backends do not close this race.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_supervise.py` - atomically publish child ownership
|
||||||
|
and reject actor starts after nursery cancellation.
|
||||||
|
- `tractor/spawn/_mp.py` - register before synchronous process startup
|
||||||
|
and abort a cancellation-owned child.
|
||||||
|
- `tractor/spawn/_trio.py` - register immediately after process creation,
|
||||||
|
kill a cancellation-owned child, and remove its stale unused import.
|
||||||
|
- `tests/test_to_actor.py` - cover late registration and MP startup
|
||||||
|
suppression.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human required review extras to be handled one item and one
|
||||||
|
behavioral commit at a time, with each item compared against PR #484
|
||||||
|
before acceptance. That direction split this late-registration fix from
|
||||||
|
the original broad experiment's bounded post-ack reaping,
|
||||||
|
`ActorNursery.cancel()` hard-reap rewrite, and debugger/error behavior.
|
||||||
|
The human accepted the narrower late-registration boundary by requesting
|
||||||
|
its commit plan. No direct source-line edits were made by the human.
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-21T04:08:03Z
|
||||||
|
git_ref: 3c1bbe73
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Compare the remaining child-registration and reaping experiment with
|
||||||
|
PR #484, then identify the next review item without changing code.
|
||||||
|
|
||||||
|
The next item is the late-child admission race. A spawn can pass
|
||||||
|
`ActorNursery.start_actor()`'s early cancellation check, then be absent
|
||||||
|
from `ActorNursery.cancel()`'s child snapshot and register afterward.
|
||||||
|
The existing reap-request latch releases its monitor but does not send
|
||||||
|
runtime cancellation, so the monitor can wait forever for a still-live
|
||||||
|
process.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_supervise.py`
|
||||||
|
|
||||||
|
`ActorNursery._register_child()` publishes the child, installs its reap
|
||||||
|
events, and samples `ActorNursery._cancel_called` without a checkpoint.
|
||||||
|
The two scheduler orderings are then complete: registration first puts
|
||||||
|
the child in the cancel snapshot, while cancellation first makes the
|
||||||
|
backend abort the late registration.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_mp.py`
|
||||||
|
|
||||||
|
The multiprocessing backend registers immediately before `proc.start()`
|
||||||
|
and refuses to start a process already owned by nursery cancellation.
|
||||||
|
There is no Trio checkpoint between registration and process startup.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_trio.py`
|
||||||
|
|
||||||
|
The Trio backend registers immediately after `open_process()` and kills
|
||||||
|
the newly opened process if cancellation won the registration race. Its
|
||||||
|
stale unused `get_runtime_vars` import is removed so the touched module
|
||||||
|
remains lint-clean.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/test_to_actor.py`
|
||||||
|
|
||||||
|
Deterministic regressions prove late registration observes cancellation
|
||||||
|
and that the MP backend never starts a process after cancellation owns
|
||||||
|
its registration.
|
||||||
|
|
||||||
|
PR #484 retains the affected generic nursery and spawn-backend paths and
|
||||||
|
does not close this race. Keep this fix in PR #481 as its own commit;
|
||||||
|
review bounded post-`CancelAck` reaping separately.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-21T05:20:52Z
|
||||||
|
git_ref: 3690e43a
|
||||||
|
scope: config
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260821T052052Z_3690e43a_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human asked for a main-first patch using an off-the-shelf pytest
|
||||||
|
plugin to cope with tractor's changing macOS CI flakes without mixing
|
||||||
|
that mitigation into PR #505.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added `pytest-rerunfailures` to tractor's testing dependencies and
|
||||||
|
configured the GitHub Actions matrix to retry failures only on macOS.
|
||||||
|
Linux and Windows remain strict first-attempt runs, while persistent
|
||||||
|
macOS failures still fail after two visible reruns.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `.github/workflows/ci.yml` - macOS-only pytest rerun budget.
|
||||||
|
- `pyproject.toml` - testing plugin dependency and rationale.
|
||||||
|
- `uv.lock` - resolved `pytest-rerunfailures` package metadata.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected a main-first mitigation after PR #505 failed two
|
||||||
|
different macOS tests on consecutive runs and required the change to
|
||||||
|
remain an incremental patch with its own commit plan. The agent
|
||||||
|
implemented and verified that direction; no direct manual source
|
||||||
|
edits were observed.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-21T05:20:52Z
|
||||||
|
git_ref: 3690e43a
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - retry flaky macOS CI tests
|
||||||
|
|
||||||
|
The human requested an off-the-shelf pytest plugin patch suitable
|
||||||
|
for landing directly on tractor `main` after PR #505's macOS job
|
||||||
|
failed two different timing-sensitive tests on consecutive runs.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- .github/workflows/ci.yml pyproject.toml uv.lock`
|
||||||
|
|
||||||
|
Added the pytest-dev-maintained `pytest-rerunfailures` plugin and
|
||||||
|
gave only the macOS matrix leg two reruns with a one-second delay.
|
||||||
|
Linux and Windows receive a zero retry budget; deterministic macOS
|
||||||
|
failures still fail after the final attempt and reruns remain visible
|
||||||
|
in pytest output.
|
||||||
|
|
||||||
|
The lockfile is current, actionlint passed, all 471 tests collected,
|
||||||
|
and the four tests covering both observed PR #505 failure areas
|
||||||
|
passed with the rerun plugin enabled.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: d9d7df2c-7044-463f-8768-ec024718eac9
|
||||||
|
timestamp: 2026-08-24T22:20:33Z
|
||||||
|
git_ref: ce38cb6f
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260824T222033Z_ce38cb6f_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continue the PR #481 review after triage. The human explicitly accepted
|
||||||
|
the proposed merge-blocking `Context.cancel()` deadline update with
|
||||||
|
"keep" and required the work to remain limited to that review item.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Update `Context.cancel()` so one absolute deadline bounds both shielded
|
||||||
|
cancel-request publication and acknowledgement waiting. Add a focused
|
||||||
|
mocked-clock regression for the blocked-publication failure mode and run
|
||||||
|
the narrow cancellation tests.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/_context.py` - forward the cancel transaction's absolute
|
||||||
|
deadline to frame publication.
|
||||||
|
- `tests/test_to_actor.py` - prove blocked context-cancel publication is
|
||||||
|
bounded by the shared deadline.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human retained ownership of review scope and explicitly selected
|
||||||
|
"keep" for this item after receiving keep/defer/drop options. The human
|
||||||
|
required no unrelated cancellation changes and did not directly edit
|
||||||
|
source lines.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-24T22:20:33Z
|
||||||
|
git_ref: ce38cb6f
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement the approved PR #481 review update for `Context.cancel()`.
|
||||||
|
Use one absolute deadline for both cancellation-request frame
|
||||||
|
publication and acknowledgement waiting, without broadening the change
|
||||||
|
to unrelated cancellation behavior.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/_context.py`
|
||||||
|
|
||||||
|
`Context.cancel()` computes one absolute cancellation deadline, uses it
|
||||||
|
for the outer bounded wait, and forwards it through
|
||||||
|
`Portal._run_from_ns()` to shielded frame publication.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/test_to_actor.py`
|
||||||
|
|
||||||
|
A deterministic mocked-clock regression arranges a shielded blocked
|
||||||
|
publication and proves that `Context.cancel()` forwards the same deadline
|
||||||
|
which bounds the complete cancel transaction.
|
||||||
|
|
||||||
|
Run the focused cancellation deadline regressions after the edit.
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: d9d7df2c-7044-463f-8768-ec024718eac9
|
||||||
|
timestamp: 2026-08-24T22:36:14Z
|
||||||
|
git_ref: 88d538e3
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260824T223614Z_88d538e3_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continue PR #481 review remediation after committing the shared
|
||||||
|
`Context.cancel()` deadline fix. The human accepted the proposed
|
||||||
|
child-reap bookkeeping invariant, asking only that the first fix receive
|
||||||
|
its own commit plan and commit before this update began.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Check that `ActorNursery` removes its paired reap-coordination entries
|
||||||
|
together while preserving valid pre-registration and immediate-cancel
|
||||||
|
paths. Extend the existing real-runtime reap tests to prove all three
|
||||||
|
child bookkeeping mappings are empty before `to_actor.run()` returns.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_supervise.py` - assert paired reap-map cleanup.
|
||||||
|
- `tests/test_to_actor.py` - verify graceful and hard-reap bookkeeping.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human explicitly accepted this invariant update but directed the
|
||||||
|
preceding cancellation fix to be planned and committed as a separate
|
||||||
|
boundary first. No direct source-line edits were made by the human.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-24T22:36:14Z
|
||||||
|
git_ref: 88d538e3
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement the approved PR #481 child-reap bookkeeping update after the
|
||||||
|
preceding `Context.cancel()` fix was committed separately.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_supervise.py`
|
||||||
|
|
||||||
|
`ActorNursery._mark_child_reaped()` captures both reap-coordination
|
||||||
|
entries and asserts that they are either both present or both absent.
|
||||||
|
It intentionally does not require the reap-request event to be set,
|
||||||
|
because backend cancellation can reap immediately after registration.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/test_to_actor.py`
|
||||||
|
|
||||||
|
Existing real-runtime graceful and hard-reap tests verify that
|
||||||
|
`ActorNursery._children`, `ActorNursery._child_reap_requests`, and
|
||||||
|
`ActorNursery._child_reaped` are all empty before the one-shot call
|
||||||
|
returns.
|
||||||
|
|
||||||
|
Run focused bookkeeping and real-runtime reap tests after the edit.
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: d9d7df2c-7044-463f-8768-ec024718eac9
|
||||||
|
timestamp: 2026-08-24T22:53:56Z
|
||||||
|
git_ref: 2f86dd1a
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260824T225356Z_2f86dd1a_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continue PR #481 review remediation after committing the paired
|
||||||
|
`ActorNursery` reap-state invariant. The human selected "keep" for the
|
||||||
|
reviewer's request to factor a duplicated debugger predicate in
|
||||||
|
`_try_cancel_then_kill()`.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Factor the child/tree debugger predicate into a local sampler used both
|
||||||
|
before and after the cancel-RPC checkpoint. Preserve dynamic debugger
|
||||||
|
lock re-evaluation and its distinction from root-wide debug mode.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_supervise.py` - factor the duplicated debugger
|
||||||
|
predicate without changing cancellation behavior.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human explicitly selected "keep" after receiving keep/defer/drop
|
||||||
|
options for this isolated review item. During commit-plan review, the
|
||||||
|
agent found that a single pre-checkpoint snapshot could become stale;
|
||||||
|
the human selected a local helper which re-evaluates the lock after the
|
||||||
|
cancel RPC. The human then considered moving the predicate into
|
||||||
|
`.devx.debug` and accepted keeping it local after confirming that no
|
||||||
|
existing helper shares its supervisor-owned semantics. No direct
|
||||||
|
source-line edits were made by the human.
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-24T22:53:56Z
|
||||||
|
git_ref: 2f86dd1a
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement the approved PR #481 review refactor in
|
||||||
|
`_try_cancel_then_kill()` without changing debugger behavior.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_supervise.py`
|
||||||
|
|
||||||
|
Compute the child/tree debugger predicate once, reuse it in the broader
|
||||||
|
hard-kill protection predicate, and pass it directly to
|
||||||
|
`debug.maybe_wait_for_debugger()`.
|
||||||
|
|
||||||
|
Run focused debugger/cancellation coverage and lint after the edit.
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: d9d7df2c-7044-463f-8768-ec024718eac9
|
||||||
|
timestamp: 2026-08-24T23:39:57Z
|
||||||
|
git_ref: 5327b25e
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260824T233957Z_5327b25e_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continue PR #481 review remediation after committing the debugger-state
|
||||||
|
sampler. The human selected "keep" for the paired review request to use
|
||||||
|
`Aid` objects as keys in the newly added reap-coordination maps.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Migrate only `ActorNursery._child_reap_requests` and
|
||||||
|
`ActorNursery._child_reaped` to `Aid` keys. Preserve the legacy
|
||||||
|
`ActorNursery._children` `.uid` key and pass full actor identities
|
||||||
|
through the narrow process-monitor bookkeeping path.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_supervise.py` - key fresh reap maps by `Aid`.
|
||||||
|
- `tractor/spawn/_spawn.py` - pass `Aid` into completed-reap cleanup.
|
||||||
|
- `tests/test_to_actor.py` - exercise `Aid` registration keys.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human explicitly selected "keep" after reviewing the scope,
|
||||||
|
performance, and mutability tradeoffs. The human retained the legacy
|
||||||
|
tuple key for `_children` and accepted `Aid` for only the two fresh
|
||||||
|
private mappings. No direct source-line edits were made by the human.
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-24T23:39:57Z
|
||||||
|
git_ref: 5327b25e
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement the approved PR #481 review update which uses `Aid` keys for
|
||||||
|
the two fresh `ActorNursery` reap-coordination maps while preserving the
|
||||||
|
legacy `.uid` key for `ActorNursery._children`.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_supervise.py`
|
||||||
|
|
||||||
|
Type and access `_child_reap_requests` and `_child_reaped` by `Aid`.
|
||||||
|
Pass full actor identities through registration, cancellation, and
|
||||||
|
completed-reap bookkeeping, deriving `.uid` only for `_children`.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_spawn.py`
|
||||||
|
|
||||||
|
Forward `subactor.aid` when publishing completed process teardown.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/test_to_actor.py`
|
||||||
|
|
||||||
|
Update deterministic registration tests to exercise `Aid` map keys.
|
||||||
|
|
||||||
|
Run focused registration/reaping tests and the full `to_actor` suite.
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: d9d7df2c-7044-463f-8768-ec024718eac9
|
||||||
|
timestamp: 2026-08-25T01:57:42Z
|
||||||
|
git_ref: e42ecb55
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260825T015742Z_e42ecb55_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continue PR #481 review remediation after committing the `Aid` reap-map
|
||||||
|
migration. The human selected "keep" for comments explaining why both
|
||||||
|
spawn backends provisionally register children with `portal=None`.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Document that a child has no `Portal` until its IPC handshake yields a
|
||||||
|
`Channel`, and make `portal=None` explicit at both registration calls.
|
||||||
|
Identify the later replacement of each provisional entry with
|
||||||
|
`Portal(chan)`. Update the MP registration test double to accept and
|
||||||
|
assert the explicit provisional portal state. Name every registration
|
||||||
|
argument consistently in both backends.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/spawn/_mp.py` - clarify provisional MP registration.
|
||||||
|
- `tractor/spawn/_trio.py` - clarify provisional Trio registration.
|
||||||
|
- `tests/test_to_actor.py` - model explicit provisional registration.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human explicitly selected "keep" after receiving keep/defer/drop
|
||||||
|
options for this paired clarification. During local review, the human
|
||||||
|
then requested that `subactor` and `proc` also be passed by name in both
|
||||||
|
backend calls. No direct source-line edits were made by the human.
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-25T01:57:42Z
|
||||||
|
git_ref: e42ecb55
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement the approved PR #481 clarification for provisional child
|
||||||
|
registration in both process-spawn backends.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_mp.py`
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_trio.py`
|
||||||
|
|
||||||
|
Explain that `portal=None` is provisional because no `Portal` can exist
|
||||||
|
until the child completes its IPC handshake and returns a `Channel`.
|
||||||
|
Use an explicit keyword argument and identify the later replacement with
|
||||||
|
`Portal(chan)`.
|
||||||
|
|
||||||
|
Run lint and the full `to_actor` runtime suite.
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: d9d7df2c-7044-463f-8768-ec024718eac9
|
||||||
|
timestamp: 2026-08-25T02:13:19Z
|
||||||
|
git_ref: ce430fca
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260825T021319Z_ce430fca_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continue PR #481 review remediation after committing provisional child
|
||||||
|
registration clarifications. The human selected "keep" for inlining the
|
||||||
|
guarded `functools.Placeholder` lookup with a walrus assignment.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Remove the standalone placeholder assignment and bind the optional
|
||||||
|
Python 3.14 sentinel directly in the existing conditional while
|
||||||
|
preserving compatibility behavior.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/to_actor/_api.py` - inline placeholder feature detection.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human explicitly selected "keep" after receiving keep/defer/drop
|
||||||
|
options for this isolated cleanup. No direct source-line edits were made
|
||||||
|
by the human.
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-25T02:13:19Z
|
||||||
|
git_ref: ce430fca
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement the approved PR #481 review cleanup for Python 3.14 partial
|
||||||
|
placeholder detection.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/to_actor/_api.py`
|
||||||
|
|
||||||
|
Inline the guarded `functools.Placeholder` lookup into the existing
|
||||||
|
condition with a walrus assignment, preserving fallback behavior when
|
||||||
|
the attribute is unavailable.
|
||||||
|
|
||||||
|
Run partial/placeholder normalization tests and the full `to_actor`
|
||||||
|
suite.
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
NOTE: you MUST pause this work at 12:50PM EST (BEFORE your weekly
|
||||||
|
limit reset) for review by a human!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
attempt to resolve https://github.com/goodboy/tractor/issues/477
|
||||||
|
do it with /open-wkt.
|
||||||
|
|
@ -0,0 +1,403 @@
|
||||||
|
# `tractor.ipc` next-gen transport backends: the shared contract
|
||||||
|
|
||||||
|
Status: design doc / implementation spec.
|
||||||
|
Audience: any model or human implementing one of the three
|
||||||
|
sibling plans in this directory.
|
||||||
|
|
||||||
|
- [`01_tipc_backend.md`](./01_tipc_backend.md) — `AF_TIPC`
|
||||||
|
(gh #378)
|
||||||
|
- [`02_quic_iroh_backend.md`](./02_quic_iroh_backend.md) — QUIC
|
||||||
|
via `iroh` FFI, uniffi-async rewritten onto `trio` (gh #353)
|
||||||
|
- [`03_wg_tunnel_bindspace.md`](./03_wg_tunnel_bindspace.md) —
|
||||||
|
WireGuard (and other shuttle-able) tunnels as a *nested
|
||||||
|
bindspace* layer via `pyroute2` (gh #482, #443)
|
||||||
|
|
||||||
|
This doc is the **normative** description of what a `tractor`
|
||||||
|
transport backend *is* as of `main@83b34884`. Each sibling plan
|
||||||
|
assumes it and only documents its own deltas. Read this first;
|
||||||
|
do not re-derive it from the code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Why a shared contract doc
|
||||||
|
|
||||||
|
The three plans are meant to be implementable *independently and
|
||||||
|
concurrently* by different models/providers without design
|
||||||
|
drift. Everything they share — the backend duck-type, the
|
||||||
|
registration tables, the test harness plumbing, the naming and
|
||||||
|
code-style rules — lives here exactly once. If an implementer
|
||||||
|
finds this doc disagrees with `main`, **the code wins**; fix this
|
||||||
|
doc in the same PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The backend duck-type (empirical, from `_tcp.py`/`_uds.py`)
|
||||||
|
|
||||||
|
A transport backend is **one module** under `tractor/ipc/`
|
||||||
|
exposing exactly four things. There is no ABC to subclass and no
|
||||||
|
plugin entrypoint; wiring is by explicit table registration
|
||||||
|
(§2) plus one piece of reflection (§1.3).
|
||||||
|
|
||||||
|
### 1.1 `class <Proto>Address(msgspec.Struct, frozen=True)`
|
||||||
|
|
||||||
|
Structurally conforms to the `Address` `Protocol` in
|
||||||
|
`tractor/discovery/_addr.py:82`. Required surface:
|
||||||
|
|
||||||
|
| member | kind | notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `proto_key` | `ClassVar[str]` | the wire/registry key, e.g. `'tcp'`, `'uds'` |
|
||||||
|
| `unwrapped_type` | `ClassVar[type]` | the primitive tuple shape |
|
||||||
|
| `def_bindspace` | `ClassVar` | default bindspace value |
|
||||||
|
| `is_valid` | `@property -> bool` | "is this a *dialable/bindable* addr" |
|
||||||
|
| `bindspace` | `@property` | the "set of hosts"-ish scope (see below) |
|
||||||
|
| `from_addr(cls, addr)` | `@classmethod` | primitive -> wrapped, `match`-based |
|
||||||
|
| `unwrap(self)` | method | wrapped -> primitive (must be msgpack-native!) |
|
||||||
|
| `get_random(cls, bindspace=...)` | `@classmethod` | per-subactor ephemeral addr |
|
||||||
|
| `get_root(cls)` | `@classmethod` | host-singleton default registrar addr |
|
||||||
|
| `__repr__` | method | `f'{type(self).__name__}[{...}]'` house style |
|
||||||
|
|
||||||
|
Hard constraints learned from the existing two:
|
||||||
|
|
||||||
|
- **`frozen=True`.** Addresses are dict keys
|
||||||
|
(`Server.epsdict()`, `Endpoint.peer_tpts`) and are compared by
|
||||||
|
value all over the runtime.
|
||||||
|
- **`.unwrap()` output must round-trip through `msgspec` and
|
||||||
|
through `wrap_address()`.** It is what actually crosses the
|
||||||
|
wire in `SpawnSpec`/`_root_mailbox`/`_registry_addrs`, and it
|
||||||
|
is what `Actor.reg_addrs` and every test compares against. If
|
||||||
|
your unwrapped form is not *uniquely* pattern-matchable
|
||||||
|
against the other backends' forms in
|
||||||
|
`wrap_address()` (`_addr.py:230`), you have a bug that
|
||||||
|
manifests as the wrong transport being loaded — the file's own
|
||||||
|
`XXX NOTE` warns about precisely this.
|
||||||
|
|
||||||
|
⚠️ **and shape-matching does not survive 4 backends.** Adding
|
||||||
|
TIPC and iroh breaks it outright: TIPC's natural form is a
|
||||||
|
`(str, int)` — indistinguishable from `TCPAddress` — and
|
||||||
|
iroh's is a `(str, str)`, which the *existing* UDS case
|
||||||
|
(`case (_, filename) if type(filename) is str`) already
|
||||||
|
swallows. Ordering hacks and prefix-tagging (an earlier
|
||||||
|
revision of plan 01 proposed `('tipc:<stype>:<scope>', inst)`)
|
||||||
|
paper over it at best.
|
||||||
|
|
||||||
|
**The fix, and the recommended prerequisite for all three
|
||||||
|
backends: make the unwrapped form carry an explicit
|
||||||
|
proto-key, using the `multiaddr` protocol name as the
|
||||||
|
canonical spelling** — `('tcp', host, port)`,
|
||||||
|
`('unix', path)`, `('udp', ...)`, `('tipc', stype, inst,
|
||||||
|
scope)`. Then `wrap_address()` collapses from an
|
||||||
|
order-sensitive `match` to `_address_types[addr[0]]`, and the
|
||||||
|
whole collision class stops existing. Note this *also* aligns
|
||||||
|
the on-wire form with `mk_maddr()`/`parse_maddr()`, so the two
|
||||||
|
representations stop being independent inventions.
|
||||||
|
|
||||||
|
Two consequences to plan for:
|
||||||
|
- it's a **wire-format change** (`SpawnSpec`,
|
||||||
|
`_root_mailbox`, `_registry_addrs`) plus every test fixture
|
||||||
|
and downstream config (`piker`'s `[network]` table). It
|
||||||
|
wants its **own migration commit, landed before any new
|
||||||
|
backend**, not smuggled into one.
|
||||||
|
- it's the moment to **stop handing raw unwrapped tuples to
|
||||||
|
users at all.** The long-term shape is: `Address` subtypes
|
||||||
|
are the public currency and `UnwrappedAddress` becomes an
|
||||||
|
internal serialization detail — the same discipline
|
||||||
|
`ipaddress` uses (you pass `IPv4Address`, not a 4-tuple).
|
||||||
|
Public API should accept `Address|maddr-str` and treat bare
|
||||||
|
tuples as legacy-tolerated input, ideally deprecated.
|
||||||
|
- **`.get_random()` must be collision-free without a live
|
||||||
|
runtime.** See the `UDSAddress.get_random()` uuid-token
|
||||||
|
comment (`_uds.py:207-220`): with no `current_actor()` the
|
||||||
|
sockname degenerates to a pure fn of `(prefix, pid)` and two
|
||||||
|
calls in one proc alias. Mix in a `uuid4().hex[:8]` token.
|
||||||
|
- **`.bindspace` semantics**: "the address' bindable space" —
|
||||||
|
ip/host for `tcp`, the socket-file *directory* for `uds`. For
|
||||||
|
the new backends: the TIPC *scope* (§1 of plan 01), the iroh
|
||||||
|
*ALPN + relay/discovery realm* (plan 02), the netns (plan 03).
|
||||||
|
`Address.namespace` is already spec'd in the Protocol as
|
||||||
|
"the if-available OS-specific network namespace key" and is
|
||||||
|
currently unimplemented by both backends — plan 03 is the
|
||||||
|
first real consumer.
|
||||||
|
|
||||||
|
### 1.2 module-level listener lifecycle
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def start_listener(
|
||||||
|
addr: <Proto>Address,
|
||||||
|
**kwargs,
|
||||||
|
) -> trio.SocketListener # or a trio.abc.Listener, see §3
|
||||||
|
...
|
||||||
|
|
||||||
|
def close_listener( # OPTIONAL
|
||||||
|
addr: <Proto>Address,
|
||||||
|
lstnr: trio.abc.Listener,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`close_listener()` is optional; `Endpoint.close_listener()`
|
||||||
|
(`_server.py:674`) `getattr`s it and treats absence as "closing
|
||||||
|
is implicit". `uds` needs it (unlinks the sock-file), `tcp`
|
||||||
|
does not.
|
||||||
|
|
||||||
|
### 1.3 the ONE piece of reflection you must not break
|
||||||
|
|
||||||
|
`Endpoint.start_listener()` (`_server.py:656`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
tpt_mod: ModuleType = inspect.getmodule(self.addr)
|
||||||
|
lstnr = await tpt_mod.start_listener(addr=self.addr)
|
||||||
|
```
|
||||||
|
|
||||||
|
The transport module is found by `inspect.getmodule()` **on the
|
||||||
|
`Address` instance**. Therefore: *the `Address` class and its
|
||||||
|
`start_listener()`/`close_listener()` MUST live in the same
|
||||||
|
module.* Do not define the address type in `_types.py` or a
|
||||||
|
`_addrs.py` and the listener elsewhere.
|
||||||
|
|
||||||
|
Immediately after, the same method does:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if (unwrapped := lstnr.socket.getsockname()) != self.addr.unwrap():
|
||||||
|
self.addr = self.addr.from_addr(unwrapped)
|
||||||
|
```
|
||||||
|
|
||||||
|
i.e. it assumes `lstnr.socket.getsockname()` exists and that its
|
||||||
|
return value is a valid `from_addr()` input. This is fine for
|
||||||
|
TIPC (§3 of plan 01) and **is the main integration hazard for
|
||||||
|
iroh** (§3 of plan 02) — plans that break it must say so
|
||||||
|
explicitly and propose the upstream `_server.py` patch.
|
||||||
|
|
||||||
|
### 1.4 `class Msgpack<Proto>Stream(MsgpackTransport)`
|
||||||
|
|
||||||
|
Subclass `tractor.ipc._transport.MsgpackTransport`. You inherit
|
||||||
|
all framing (`<I` 4-byte little-endian length prefix),
|
||||||
|
`msgspec` codec ctx-var lookup, `TransportClosed` normalization,
|
||||||
|
`.drain()`, `__aiter__`. You implement only:
|
||||||
|
|
||||||
|
| member | notes |
|
||||||
|
| --- | --- |
|
||||||
|
| `address_type` | the `<Proto>Address` class |
|
||||||
|
| `layer_key: int` | OSI-ish layer, `4` for both current backends |
|
||||||
|
| `maddr` `@property` | `-> Multiaddr\|str`, via `mk_maddr(self.raddr)` |
|
||||||
|
| `connected(self) -> bool` | `tcp`/`uds` both use `self.stream.socket.fileno() != -1` |
|
||||||
|
| `connect_to(cls, addr, prefix_size=4, codec=None, **kw)` | `@classmethod`, returns an instance |
|
||||||
|
| `get_stream_addrs(cls, stream) -> (laddr, raddr)` | `@classmethod`, called from `MsgpackTransport.__init__` |
|
||||||
|
|
||||||
|
`MsgpackTransport.__init__` requires the object passed as
|
||||||
|
`stream` to satisfy:
|
||||||
|
|
||||||
|
- `await stream.send_all(bytes)`
|
||||||
|
- usable as `tricycle.BufferedReceiveStream(transport_stream=stream)`,
|
||||||
|
i.e. `await stream.receive_some(n)`
|
||||||
|
- `trio.BrokenResourceError` / `trio.ClosedResourceError` /
|
||||||
|
`ValueError('...unclean EOF...')` on the failure paths that
|
||||||
|
`_iter_packets()` and `send()` already `match` on
|
||||||
|
(`_transport.py:221-304`, `:436-499`).
|
||||||
|
|
||||||
|
That is **`trio.abc.Stream`, not `trio.SocketStream`**. The
|
||||||
|
`MsgTransport` Protocol's `stream: trio.SocketStream`
|
||||||
|
annotation (`_transport.py:83`) is a lie of convenience — the
|
||||||
|
actual `MsgpackTransport.__init__` param is typed
|
||||||
|
`trio.abc.Stream` and nothing in the msg path touches
|
||||||
|
`.socket`. Only `connected()` (which each backend defines) and
|
||||||
|
`Endpoint.start_listener()`'s `getsockname()` do.
|
||||||
|
|
||||||
|
### 1.5 verified-good news for socket-family backends
|
||||||
|
|
||||||
|
Both `trio.SocketStream` and `trio.SocketListener` are
|
||||||
|
**address-family agnostic**. Verified against the installed
|
||||||
|
`trio` (`trio/_highlevel_socket.py`): the only constructor
|
||||||
|
checks are
|
||||||
|
|
||||||
|
- `isinstance(socket, trio.socket.SocketType)`
|
||||||
|
- `socket.type == SOCK_STREAM`
|
||||||
|
- (listener) `getsockopt(SOL_SOCKET, SO_ACCEPTCONN)` is truthy,
|
||||||
|
with `OSError` **suppressed** (the macOS carve-out, which
|
||||||
|
also covers exotic families that reject the opt)
|
||||||
|
|
||||||
|
There is no `AF_*` check and no `IPPROTO_TCP` hard dependency
|
||||||
|
(`TCP_NODELAY`/`TCP_NOTSENT_LOWAT` are set under
|
||||||
|
`suppress(OSError)`). Consequence: **any `SOCK_STREAM` family
|
||||||
|
CPython can create — including `AF_TIPC` — drops straight into
|
||||||
|
the existing `trio.SocketStream` + `trio.serve_listeners()`
|
||||||
|
path.** This is why plan 01 is small and plan 02 is not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Registration tables (the full wiring checklist)
|
||||||
|
|
||||||
|
Adding a backend touches these and only these:
|
||||||
|
|
||||||
|
1. `tractor/runtime/_state.py:46`
|
||||||
|
`TransportProtocolKey = Literal['tcp', 'uds', ...]` — add the
|
||||||
|
key. This `Literal` is the canonical set; `_testing/pytest.py`
|
||||||
|
drives `--tpt-proto` validation off `_addr._address_types`,
|
||||||
|
and the spawn-backend fixture already models the
|
||||||
|
"drive-the-set-from-the-Literal" pattern
|
||||||
|
(`pytest.py:870-880`) — do the same rather than hardcoding.
|
||||||
|
2. `tractor/discovery/_addr.py:173` `_address_types: bidict` —
|
||||||
|
`{'<key>': <Proto>Address}`. Note it is a **`bidict`**, so
|
||||||
|
the mapping must stay 1:1.
|
||||||
|
3. `tractor/discovery/_addr.py:181` `_default_lo_addrs` —
|
||||||
|
`'<key>': <Proto>Address.get_root().unwrap()`.
|
||||||
|
⚠️ this dict is built at **import time**, so
|
||||||
|
`get_root()` must not require a live runtime, a loaded kernel
|
||||||
|
module, or network I/O. (`UDSAddress.def_bindspace =
|
||||||
|
get_rt_dir()` is the precedent for "cheap, pure, filesystem-
|
||||||
|
ish".) A backend whose root addr needs I/O must make this
|
||||||
|
entry lazy — propose that refactor explicitly.
|
||||||
|
4. `tractor/discovery/_addr.py:230` `wrap_address()` `match` —
|
||||||
|
add a case iff your `unwrapped_type` isn't already uniquely
|
||||||
|
matched. **Preferably do the proto-key migration in §1.1
|
||||||
|
first**, after which this step becomes a one-line
|
||||||
|
`_address_types` entry instead of an order-sensitive `case`.
|
||||||
|
5. `tractor/ipc/_types.py` — `Address` union alias,
|
||||||
|
`_msg_transports` list, `_key_to_transport[('msgpack', key)]`,
|
||||||
|
`_addr_to_transport[<Proto>Address]`.
|
||||||
|
6. `tractor/ipc/_types.py:92` `transport_from_stream()` — the
|
||||||
|
`sock.family` `match`. For a non-socket stream type (iroh)
|
||||||
|
this needs a different discriminator; see plan 02 §3.3.
|
||||||
|
7. `tractor/discovery/_multiaddr.py` —
|
||||||
|
`_tpt_proto_to_maddr`, and a `case` in both `mk_maddr()` and
|
||||||
|
`parse_maddr()`.
|
||||||
|
8. `tractor/ipc/__init__.py` — re-export if the backend has a
|
||||||
|
public surface.
|
||||||
|
9. `tractor/_testing/addr.py::get_rando_addr()` — per-proto
|
||||||
|
branch so the whole suite can run under `--tpt-proto <key>`.
|
||||||
|
10. `pyproject.toml` — new deps go in an **optional extra**, never
|
||||||
|
in `[project].dependencies`. See §5.
|
||||||
|
|
||||||
|
## 3. Where the `trio.SocketListener` assumption is load-bearing
|
||||||
|
|
||||||
|
`_serve_ipc_eps()` (`_server.py:1041`) annotates
|
||||||
|
`listener: trio.abc.Listener` and hands the list to
|
||||||
|
`trio.serve_listeners(handler=handle_stream_from_peer,
|
||||||
|
listeners=..., handler_nursery=stream_handler_tn)`.
|
||||||
|
`trio.serve_listeners` itself is generic over
|
||||||
|
`trio.abc.Listener`. So the *only* `SocketListener`-specific
|
||||||
|
code in the server path is the `getsockname()` reconciliation in
|
||||||
|
`Endpoint.start_listener()` (§1.3) and the type annotations.
|
||||||
|
|
||||||
|
`handle_stream_from_peer()` (`_server.py:298`) then does
|
||||||
|
`Channel.from_stream(stream)` →
|
||||||
|
`transport_from_stream(stream)` → `sock.family` match (§2.6).
|
||||||
|
|
||||||
|
**Therefore**: a non-socket backend needs (a) a
|
||||||
|
`trio.abc.Listener` subclass, (b) a change to
|
||||||
|
`Endpoint.start_listener()` to not blindly `getsockname()`, and
|
||||||
|
(c) a change to `transport_from_stream()`'s discrimination.
|
||||||
|
All three are small, upstream-able, and *should be landed as
|
||||||
|
their own prep PR* before the backend itself — see plan 02 §3.
|
||||||
|
|
||||||
|
## 4. Handshake / discovery invariants you inherit
|
||||||
|
|
||||||
|
- Every accepted stream immediately does
|
||||||
|
`chan._do_handshake(aid=actor.aid)`; a peer that fails it is
|
||||||
|
logged at `runtime` and dropped, **not** raised
|
||||||
|
(`_server.py:334-365`). Discovery-sys "pings" rely on this,
|
||||||
|
so your `connect_to()` must raise something that normalizes
|
||||||
|
to `TransportClosed`/`ConnectionError` on a dead peer, never
|
||||||
|
a novel exception type.
|
||||||
|
- `_root.py:381-406` fail-fasts when a `registry_addrs` entry's
|
||||||
|
`proto_key` is not in `enable_transports`. Your key must be
|
||||||
|
spellable in both.
|
||||||
|
- `_root.py:256` currently enforces `len(enable_transports) == 1`.
|
||||||
|
Multi-tpt actors are a separate work item; none of these three
|
||||||
|
plans may depend on lifting it.
|
||||||
|
- Sub-actor bind addrs come from
|
||||||
|
`_runtime.py:1600-1610`: for each key in the parent-supplied
|
||||||
|
`enable_transports`, `get_address_cls(key).get_random()`.
|
||||||
|
So `get_random()` runs *in the child, post-fork, pre-listen*.
|
||||||
|
Anything it needs (kernel module, netns membership, an iroh
|
||||||
|
secret key) must already be true at that moment.
|
||||||
|
|
||||||
|
## 5. Dependency policy
|
||||||
|
|
||||||
|
`[project].dependencies` stays lean (see the boot-latency work,
|
||||||
|
gh #470: `import tractor` is budgeted at ~0.145s). Every new
|
||||||
|
backend dep is an extra:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.optional-dependencies]
|
||||||
|
tipc = [] # stdlib-only!
|
||||||
|
quic = ["iroh>=0.35"] # pin per plan 02 §1
|
||||||
|
wg = ["pyroute2>=0.9"] # pin per plan 03 §1
|
||||||
|
```
|
||||||
|
|
||||||
|
and every backend module must be **import-lazy**: a
|
||||||
|
`tractor/ipc/_<proto>.py` that imports its 3rd-party dep at
|
||||||
|
module scope must not be imported by `tractor/__init__.py`,
|
||||||
|
`tractor/ipc/__init__.py`, or `tractor/discovery/_addr.py`'s
|
||||||
|
import-time table construction. The `_addr._default_lo_addrs`
|
||||||
|
eager-dict (§2.3) is the trap: keep the backend's `get_root()`
|
||||||
|
dep-free, or make that table lazy.
|
||||||
|
|
||||||
|
## 6. Test-harness plumbing (identical for all three)
|
||||||
|
|
||||||
|
- `--tpt-proto <key>` (`_testing/pytest.py:409`) selects the
|
||||||
|
session-wide proto; the `tpt_proto` fixture mutates
|
||||||
|
`_state._def_tpt_proto` + `_runtime_vars['_enable_tpts']`
|
||||||
|
(`pytest.py:807-835`). Adding the key to `_address_types` is
|
||||||
|
what makes `--tpt-proto <key>` legal (`pytest.py:795-800`
|
||||||
|
asserts the lookup).
|
||||||
|
- The **acceptance bar** for every backend is: the *entire*
|
||||||
|
existing suite passes under `--tpt-proto <key>`, unmodified.
|
||||||
|
That is the whole point of the abstraction. Backend-specific
|
||||||
|
unit tests go in `tests/ipc/test_each_tpt.py` (the existing
|
||||||
|
`test_uds_bindspace_created_implicitly` /
|
||||||
|
`test_uds_double_listen_raises_connerr` are the model).
|
||||||
|
- Capability gating: each backend needs a **cheap, pure
|
||||||
|
predicate** + a `pytest.mark.skipif`, because these are all
|
||||||
|
environment-dependent. Verified example: on this dev box
|
||||||
|
`socket.socket(AF_TIPC, SOCK_STREAM)` raises
|
||||||
|
`OSError(97, 'Address family not supported by protocol')`
|
||||||
|
because the `tipc` module isn't loaded. Put the predicate in
|
||||||
|
the backend module (so apps can use it too), not in the test.
|
||||||
|
- New pytest marks must be registered in `pyproject.toml`, per
|
||||||
|
the project's fix-warnings-at-source rule (gh #469).
|
||||||
|
|
||||||
|
## 7. Code style (non-negotiable, matches the repo)
|
||||||
|
|
||||||
|
- module header tagline: `# tractor: distributed structured
|
||||||
|
concurrency.` for **new** files (not the legacy
|
||||||
|
`structured concurrent "actors".` form the existing `_tcp.py`
|
||||||
|
carries).
|
||||||
|
- AGPL header block copied verbatim from `_tcp.py`.
|
||||||
|
- `from __future__ import annotations` first.
|
||||||
|
- annotate *everything*, including locals:
|
||||||
|
`sockpath: Path = addr.sockpath`.
|
||||||
|
- `match`/`case` over `isinstance` chains for address and
|
||||||
|
error dispatch.
|
||||||
|
- multi-line call/`import` style with trailing commas.
|
||||||
|
- never emit a whitespace-only line.
|
||||||
|
- error messages are multi-line f-strings ending in `\n`, with
|
||||||
|
the `f'...\n' f'...\n'` implicit-concat layout and the
|
||||||
|
`>[`/`[>`/`<=(` nested-op sigils where a `nest_from_op()` is
|
||||||
|
in play.
|
||||||
|
- prefer pure functions + module-level helpers over methods;
|
||||||
|
keep `Address` types data-only. Where a helper needs
|
||||||
|
scoped setup/teardown, it's an `@acm` — not a class with
|
||||||
|
`.start()`/`.stop()`.
|
||||||
|
- pure getters: no `get_*(..., mutate=True)` flags; split into
|
||||||
|
a read-only getter and an explicit sibling setter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Cross-plan sequencing
|
||||||
|
|
||||||
|
The three are independent *except*:
|
||||||
|
|
||||||
|
- plan 02 (iroh) needs the `_server.py` /
|
||||||
|
`transport_from_stream()` generalization (§3) — plan 01 does
|
||||||
|
**not**, and should therefore land first as the cheap proof
|
||||||
|
that the table-registration story works for a genuinely new
|
||||||
|
proto.
|
||||||
|
- plan 03 (wg) composes *under* whatever L4 tpt is in use and
|
||||||
|
its netns work is what finally implements
|
||||||
|
`Address.namespace`. It can land before or after 02, but its
|
||||||
|
`TunnelledAddress` design must be reviewed against plan 02's
|
||||||
|
address shape so the "tunnelled maddr" grammar (gh #443)
|
||||||
|
covers `/…/quic-v1/p2p/…` inner addrs too.
|
||||||
|
- All three want first-class `wg`/`quic`/`tipc` protos in
|
||||||
|
`py-multiaddr`; that upstream track is gh #483 and
|
||||||
|
multiformats/py-multiaddr#107/#108.
|
||||||
|
|
@ -0,0 +1,707 @@
|
||||||
|
# Plan 01 — `TIPC` transport backend (`tractor/ipc/_tipc.py`)
|
||||||
|
|
||||||
|
Tracks gh [#378]. Prereq reading:
|
||||||
|
[`00_shared_backend_contract.md`](./00_shared_backend_contract.md).
|
||||||
|
|
||||||
|
**Thesis**: TIPC is the *cheapest* new backend we can add and
|
||||||
|
simultaneously the only one that gives us cluster-wide service
|
||||||
|
discovery **for free, in the kernel**, replacing (for
|
||||||
|
TIPC-capable deployments) the whole `tractor.discovery`
|
||||||
|
registrar round-trip with a `bind()`/`connect()` on a
|
||||||
|
*service name*. It is stdlib-only: zero new dependencies.
|
||||||
|
|
||||||
|
[#378]: https://github.com/goodboy/tractor/issues/378
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this is small: three verified facts
|
||||||
|
|
||||||
|
1. **CPython already speaks TIPC.** `socket.AF_TIPC` plus 23
|
||||||
|
`TIPC_*` constants are present in the stdlib on Linux
|
||||||
|
(verified on the dev box, py3.13):
|
||||||
|
`AF_TIPC, SOL_TIPC, TIPC_ADDR_ID, TIPC_ADDR_NAME,
|
||||||
|
TIPC_ADDR_NAMESEQ, TIPC_CFG_SRV, TIPC_CLUSTER_SCOPE,
|
||||||
|
TIPC_CONN_TIMEOUT, TIPC_{CRITICAL,HIGH,MEDIUM,LOW}_IMPORTANCE,
|
||||||
|
TIPC_DEST_DROPPABLE, TIPC_IMPORTANCE, TIPC_NODE_SCOPE,
|
||||||
|
TIPC_PUBLISHED, TIPC_SRC_DROPPABLE, TIPC_SUBSCR_TIMEOUT,
|
||||||
|
TIPC_SUB_CANCEL, TIPC_SUB_PORTS, TIPC_SUB_SERVICE,
|
||||||
|
TIPC_TOP_SRV, TIPC_WAIT_FOREVER, TIPC_WITHDRAWN,
|
||||||
|
TIPC_ZONE_SCOPE`.
|
||||||
|
`sock.bind()/connect()/getsockname()` take/return the
|
||||||
|
5-tuple `(addr_type, v1, v2, v3, scope)` — the last element
|
||||||
|
is optional on input and defaults to `0`.
|
||||||
|
2. **`trio` doesn't care about the address family.** Per
|
||||||
|
contract §1.5, `trio.SocketStream` and `trio.SocketListener`
|
||||||
|
only require a trio socket object of type `SOCK_STREAM`.
|
||||||
|
TIPC's `SOCK_STREAM` is a real connection-oriented reliable
|
||||||
|
byte stream. So we reuse `trio.SocketStream`,
|
||||||
|
`trio.SocketListener`, `trio.serve_listeners()`,
|
||||||
|
`MsgpackTransport`'s framing — *all of it*.
|
||||||
|
3. **It is not available by default.** On this box
|
||||||
|
`socket.socket(AF_TIPC, SOCK_STREAM)` →
|
||||||
|
`OSError(97, 'Address family not supported by protocol')`
|
||||||
|
with no `tipc` in `/proc/modules`. `modprobe tipc` is
|
||||||
|
required; cross-node needs a bearer
|
||||||
|
(`tipc bearer enable media eth device <if>` or
|
||||||
|
`media udp name <n> localip <ip>`). Everything about this
|
||||||
|
plan's testability hinges on gating (§7).
|
||||||
|
|
||||||
|
Non-goals: `SOCK_RDM`/`SOCK_DGRAM`/`SOCK_SEQPACKET` message
|
||||||
|
modes, multicast fan-out, and TIPC group messaging. They are
|
||||||
|
genuinely interesting for a future `tractor` broadcast/pubsub
|
||||||
|
transport but they do **not** fit `MsgTransport`'s
|
||||||
|
stream-of-length-prefixed-msgs shape. Note them in the
|
||||||
|
follow-up issue, do not build them here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `TIPCAddress`
|
||||||
|
|
||||||
|
### 2.1 the three TIPC address flavours, and which we use
|
||||||
|
|
||||||
|
| flavour | tuple | meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `TIPC_ADDR_NAMESEQ` | `(type, lower, upper, scope)` | a *published range* — what a server `bind()`s |
|
||||||
|
| `TIPC_ADDR_NAME` | `(type, instance, domain, scope)` | a *lookup* — what a client `connect()`s |
|
||||||
|
| `TIPC_ADDR_ID` | `(node, ref, 0, scope)` | a concrete port id — the "physical" address |
|
||||||
|
|
||||||
|
The design decision that makes this backend coherent:
|
||||||
|
|
||||||
|
> **A `tractor` actor's TIPC address is a *service name*
|
||||||
|
> `(type, instance)`; `bind()` publishes the singleton range
|
||||||
|
> `(type, instance, instance)`; peers `connect()` by name and
|
||||||
|
> the kernel resolves + load-balances. `TIPC_ADDR_ID` is only
|
||||||
|
> ever an *observed* address (`getpeername()`), never a
|
||||||
|
> user-facing one.**
|
||||||
|
|
||||||
|
This is exactly the "leverage the built-in discovery machinery"
|
||||||
|
ask in #378: publishing a bind *is* registration, and
|
||||||
|
`connect()` on a name *is* a lookup, with no registrar actor in
|
||||||
|
the loop.
|
||||||
|
|
||||||
|
### 2.2 the struct
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TIPCAddress(
|
||||||
|
msgspec.Struct,
|
||||||
|
frozen=True,
|
||||||
|
):
|
||||||
|
_stype: int # TIPC "type" == service class
|
||||||
|
_instance: int # service instance within the type
|
||||||
|
_scope: int = TIPC_CLUSTER_SCOPE
|
||||||
|
# observed-only, never part of identity/equality-by-intent
|
||||||
|
maybe_node: int|None = None # from TIPC_ADDR_ID getpeername()
|
||||||
|
maybe_ref: int|None = None
|
||||||
|
|
||||||
|
proto_key: ClassVar[str] = 'tipc'
|
||||||
|
unwrapped_type: ClassVar[type] = tuple[str, int]
|
||||||
|
def_bindspace: ClassVar[int] = TIPC_CLUSTER_SCOPE
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unwrapped form** (the wire/`SpawnSpec` shape).
|
||||||
|
|
||||||
|
TIPC's natural form is `(stype, instance, scope)` — but a
|
||||||
|
2-tuple squeeze of it is a `(str, int)`, i.e. *the same coarse
|
||||||
|
shape as `TCPAddress`*, so `wrap_address()`'s
|
||||||
|
`case (str(), int())` steals it. This backend is therefore the
|
||||||
|
forcing function for the contract-doc's conclusion (§1.1):
|
||||||
|
|
||||||
|
> **make the unwrapped form carry an explicit proto-key, spelled
|
||||||
|
> with the `multiaddr` protocol name.**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def unwrap(self) -> tuple[str, int, int, int]:
|
||||||
|
return ('tipc', self._stype, self._instance, self._scope)
|
||||||
|
```
|
||||||
|
|
||||||
|
`wrap_address()` then dispatches `_address_types[addr[0]]` and
|
||||||
|
the collision class disappears. **This is a prerequisite
|
||||||
|
migration commit, not part of this backend** — see contract §1.1
|
||||||
|
for its blast radius (wire format + every fixture + `piker`
|
||||||
|
config) and for the follow-on "stop handing raw tuples to users
|
||||||
|
at all, à la `ipaddress`" direction.
|
||||||
|
|
||||||
|
⚠️ an earlier revision of this plan proposed a self-tagging
|
||||||
|
`('tipc:<stype>:<scope>', instance)` string-prefix hack with an
|
||||||
|
ordered `case` guard. **Dropped** — it papers over the problem,
|
||||||
|
keeps `wrap_address()` order-sensitive, and doesn't help iroh's
|
||||||
|
`(str, str)`-vs-UDS collision at all. Do not resurrect it.
|
||||||
|
|
||||||
|
Note `TIPCAddress` is the first backend where `.unwrap()` is
|
||||||
|
**not** a lossless view of the live socket — `maybe_node`/
|
||||||
|
`maybe_ref` are observed metadata, exactly like
|
||||||
|
`UDSAddress.maybe_pid` (which is likewise excluded from
|
||||||
|
`.unwrap()`). Follow that precedent, including its `__repr__`
|
||||||
|
treatment (`_uds.py:242`).
|
||||||
|
|
||||||
|
### 2.3 how to pick `_stype` and `_instance`
|
||||||
|
|
||||||
|
- `_stype` = a `tractor`-reserved service class. TIPC reserves
|
||||||
|
0..63 for internal use (`TIPC_TOP_SRV == 1`,
|
||||||
|
`TIPC_CFG_SRV == 0`). Use a module constant
|
||||||
|
`TRACTOR_STYPE: int = 0x74_72_00_00` ("tr\0\0") as the default
|
||||||
|
and make it overridable via `TIPCAddress._stype` so an app
|
||||||
|
can partition service classes. Document that two `tractor`
|
||||||
|
trees sharing a cluster **and** a `_stype` share a namespace.
|
||||||
|
- `_instance` for `get_root()`: `1616` — mirrors the
|
||||||
|
`TCPAddress.get_root()` port and the `registry@1616.sock`
|
||||||
|
UDS filename, so the "1616 is tractor's registrar" idiom
|
||||||
|
holds across all backends.
|
||||||
|
- `_instance` for `get_random()`: TIPC gives us no
|
||||||
|
kernel-assigned-instance analogue of `port=0`, so we must
|
||||||
|
choose. Use a *pure* fn of the actor identity so it is
|
||||||
|
reproducible and collision-free:
|
||||||
|
```python
|
||||||
|
# 32-bit instance derived from the actor's uuid4 (+ pid when
|
||||||
|
# there's no live runtime, per the UDS precedent).
|
||||||
|
inst: int = int.from_bytes(
|
||||||
|
blake2b(seed.encode(), digest_size=4).digest(),
|
||||||
|
'big',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
where `seed = f'{actor.aid.name}@{pid}'` if
|
||||||
|
`current_actor(err_on_no_runtime=False)` else
|
||||||
|
`f'{prefix}.{uuid4().hex[:8]}@{pid}'`. Must avoid the reserved
|
||||||
|
low range: `inst = 64 + (inst % (2**32 - 64))`.
|
||||||
|
⚠️ *unlike* `port=0`, a collision here surfaces as a
|
||||||
|
successful-but-shared publication (TIPC allows multiple
|
||||||
|
binders on the same name and round-robins!) rather than
|
||||||
|
`EADDRINUSE`. That is a silent-crosstalk failure mode; §7 has
|
||||||
|
the test that proves the 4-byte digest is enough and §9 has
|
||||||
|
the mitigation if it isn't.
|
||||||
|
- `_scope`: `TIPC_NODE_SCOPE` for a same-host-only actor (the
|
||||||
|
UDS-equivalent), `TIPC_CLUSTER_SCOPE` (default) for
|
||||||
|
cluster-visible. **This is `.bindspace`**:
|
||||||
|
```python
|
||||||
|
@property
|
||||||
|
def bindspace(self) -> int:
|
||||||
|
return self._scope
|
||||||
|
```
|
||||||
|
It is the honest analogue of "the set of hosts this bind is
|
||||||
|
reachable from", which is precisely the docstring in
|
||||||
|
`Address.bindspace`. (`TIPC_ZONE_SCOPE` is deprecated/aliased
|
||||||
|
to cluster in modern kernels — accept it on input, normalize
|
||||||
|
to cluster, log at `transport` level.)
|
||||||
|
|
||||||
|
### 2.4 `is_valid`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@property
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
return (
|
||||||
|
self._instance != 0
|
||||||
|
and
|
||||||
|
self._stype not in _tipc_reserved_stypes # {0, 1, ...}
|
||||||
|
and
|
||||||
|
self._scope in (TIPC_NODE_SCOPE, TIPC_CLUSTER_SCOPE)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Listener + stream
|
||||||
|
|
||||||
|
### 3.1 `start_listener()`
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def start_listener(
|
||||||
|
addr: TIPCAddress,
|
||||||
|
backlog: int = 128,
|
||||||
|
**kwargs,
|
||||||
|
) -> SocketListener:
|
||||||
|
sock = trio.socket.socket(
|
||||||
|
socket.AF_TIPC,
|
||||||
|
socket.SOCK_STREAM,
|
||||||
|
)
|
||||||
|
# publish the singleton name-range == "register the service"
|
||||||
|
await sock.bind((
|
||||||
|
socket.TIPC_ADDR_NAMESEQ,
|
||||||
|
addr._stype,
|
||||||
|
addr._instance,
|
||||||
|
addr._instance,
|
||||||
|
addr._scope,
|
||||||
|
))
|
||||||
|
sock.listen(backlog)
|
||||||
|
return SocketListener(sock)
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes / hazards:
|
||||||
|
|
||||||
|
- `bind()` on `AF_TIPC` is **not** a filesystem or port-table
|
||||||
|
operation and can't block on DNS, but keep it `await`ed
|
||||||
|
through `trio.socket` anyway for uniformity.
|
||||||
|
- `backlog=128` matching `_uds.start_listener()`'s hard-won
|
||||||
|
value (see its comment at `_uds.py:317-331` re: concurrent
|
||||||
|
deregistration storms). Do not use `1`.
|
||||||
|
- **no `close_listener()` needed** — nothing to unlink. Omit the
|
||||||
|
function entirely (contract §1.2: absence means implicit).
|
||||||
|
Withdrawal of the published name happens on socket close.
|
||||||
|
- ⚠️ `SocketListener.__init__` will try
|
||||||
|
`getsockopt(SOL_SOCKET, SO_ACCEPTCONN)`. If TIPC rejects it,
|
||||||
|
trio's `except OSError: pass` covers us. Assert this in a
|
||||||
|
unit test rather than assuming.
|
||||||
|
- Wrap the bind in a `_reraise_as_connerr()`-style `@cm` (copy
|
||||||
|
the `_uds.py:256` pattern) so `EADDRINUSE`-ish and
|
||||||
|
`EAFNOSUPPORT` become `ConnectionError` with the addr in the
|
||||||
|
message. `EAFNOSUPPORT` here means "kernel module not
|
||||||
|
loaded" and deserves a *specifically actionable* message:
|
||||||
|
`'TIPC unavailable — try `sudo modprobe tipc`\n'`.
|
||||||
|
|
||||||
|
### 3.2 the `getsockname()` reconciliation
|
||||||
|
|
||||||
|
`Endpoint.start_listener()` does
|
||||||
|
`if lstnr.socket.getsockname() != self.addr.unwrap(): self.addr =
|
||||||
|
self.addr.from_addr(unwrapped)`.
|
||||||
|
|
||||||
|
For TIPC, `getsockname()` on a bound-but-listening socket
|
||||||
|
returns a `TIPC_ADDR_ID`-flavoured 5-tuple (the port id), *not*
|
||||||
|
the name-seq we bound. So the `!=` is **always true** and
|
||||||
|
`from_addr()` will be handed a 5-tuple.
|
||||||
|
|
||||||
|
Handle it inside `TIPCAddress.from_addr()` — do **not** patch
|
||||||
|
`_server.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@classmethod
|
||||||
|
def from_addr(cls, addr) -> TIPCAddress:
|
||||||
|
match addr:
|
||||||
|
# our own unwrapped form
|
||||||
|
case (str() as tag, int() as inst) if tag.startswith('tipc:'):
|
||||||
|
_, stype, scope = tag.split(':')
|
||||||
|
return TIPCAddress(int(stype), inst, int(scope))
|
||||||
|
|
||||||
|
# a kernel-observed TIPC_ADDR_ID 5-tuple: keep the
|
||||||
|
# *service* identity we already know and only annotate
|
||||||
|
# the observed port-id.
|
||||||
|
case (int() as atype, *rest) if atype == socket.TIPC_ADDR_ID:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
The `TIPC_ADDR_ID` case cannot reconstruct `(stype, instance)`
|
||||||
|
— that info isn't in a port id. So `from_addr()` alone is
|
||||||
|
insufficient for the reconciliation path. **Resolution**: make
|
||||||
|
`from_addr()` raise a clear `ValueError` for the bare
|
||||||
|
`TIPC_ADDR_ID` case, and instead prevent the reconciliation
|
||||||
|
from firing by having `start_listener()` return a listener
|
||||||
|
whose `getsockname()` we never need — i.e. land this two-line
|
||||||
|
upstream fix in `_server.py:664`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if (
|
||||||
|
(unwrapped := lstnr.socket.getsockname()) != self.addr.unwrap()
|
||||||
|
and
|
||||||
|
self.addr.rebind_from_sockname # ClassVar[bool] = True on tcp/uds
|
||||||
|
):
|
||||||
|
```
|
||||||
|
|
||||||
|
with `TIPCAddress.rebind_from_sockname: ClassVar[bool] = False`
|
||||||
|
(and `True` on `TCPAddress`/`UDSAddress`, preserving today's
|
||||||
|
behaviour exactly). Rationale: the reconciliation exists *only*
|
||||||
|
to learn the kernel-assigned port for `port=0` TCP binds (its
|
||||||
|
own comment says so, `_server.py:662`); TIPC has no such
|
||||||
|
late-binding, so opting out is semantically right rather than a
|
||||||
|
hack. **Land this as its own commit, ahead of the backend**,
|
||||||
|
with a test that `tcp`'s `port=0` behaviour is unchanged.
|
||||||
|
|
||||||
|
Keep the observed port-id available anyway: annotate
|
||||||
|
`ep.addr = ep.addr.with_port_id(*getsockname()[1:3])` (a pure
|
||||||
|
`msgspec.structs.replace()` helper) purely for logging/repr.
|
||||||
|
|
||||||
|
### 3.3 `MsgpackTIPCStream`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MsgpackTIPCStream(MsgpackTransport):
|
||||||
|
address_type = TIPCAddress
|
||||||
|
layer_key: int = 4
|
||||||
|
|
||||||
|
@property
|
||||||
|
def maddr(self) -> Multiaddr|str:
|
||||||
|
return mk_maddr(self.raddr)
|
||||||
|
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self.stream.socket.fileno() != -1
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def connect_to(
|
||||||
|
cls,
|
||||||
|
destaddr: TIPCAddress,
|
||||||
|
prefix_size: int = 4,
|
||||||
|
codec: MsgCodec|None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> MsgpackTIPCStream:
|
||||||
|
sock = trio.socket.socket(AF_TIPC, SOCK_STREAM)
|
||||||
|
with close_on_error(sock):
|
||||||
|
# NOTE: connect by *name* -> kernel does the lookup,
|
||||||
|
# so this is our "discovery" call.
|
||||||
|
await sock.connect((
|
||||||
|
socket.TIPC_ADDR_NAME,
|
||||||
|
destaddr._stype,
|
||||||
|
destaddr._instance,
|
||||||
|
0, # domain: 0 == "anywhere in scope"
|
||||||
|
destaddr._scope,
|
||||||
|
))
|
||||||
|
return cls(
|
||||||
|
trio.SocketStream(sock),
|
||||||
|
prefix_size=prefix_size,
|
||||||
|
codec=codec,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- reuse `trio._highlevel_open_unix_stream.close_on_error` (the
|
||||||
|
UDS backend already imports it) or inline the equivalent
|
||||||
|
`try/except: sock.close(); raise`.
|
||||||
|
- `SO_/TIPC_` opts worth setting and documenting:
|
||||||
|
- `setsockopt(SOL_TIPC, TIPC_IMPORTANCE, TIPC_HIGH_IMPORTANCE)`
|
||||||
|
for the *parent<->child* lifetime channel — this is a real
|
||||||
|
win TIPC gives us that TCP can't: the runtime's
|
||||||
|
supervision channel can outrank bulk app traffic under
|
||||||
|
congestion. Wire it as a `connect_to(..., importance=...)`
|
||||||
|
kwarg defaulted from a module constant, and have
|
||||||
|
`_runtime.py`'s parent-chan path pass the high value **in a
|
||||||
|
follow-up** (don't couple it to this PR).
|
||||||
|
- `TIPC_CONN_TIMEOUT` — the kernel-side connect timeout;
|
||||||
|
leave at default, we have `trio` cancel scopes.
|
||||||
|
- `TIPC_DEST_DROPPABLE = 0` on the connection so undeliverable
|
||||||
|
msgs come back as errors rather than being silently dropped.
|
||||||
|
- **`connect_to()` on a name with no publisher**: TIPC returns
|
||||||
|
`ECONNREFUSED`/`EHOSTUNREACH` promptly (no SYN-timeout wait),
|
||||||
|
which is *better* discovery-ping behaviour than TCP. Confirm
|
||||||
|
the errno and make sure it surfaces as `ConnectionError`
|
||||||
|
(contract §4 — the registrar ping path depends on it).
|
||||||
|
|
||||||
|
### 3.4 `get_stream_addrs()`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@classmethod
|
||||||
|
def get_stream_addrs(cls, stream) -> tuple[TIPCAddress, TIPCAddress]:
|
||||||
|
sock = stream.socket
|
||||||
|
# both return TIPC_ADDR_ID 5-tuples for a connected sock
|
||||||
|
l_id = sock.getsockname()
|
||||||
|
r_id = sock.getpeername()
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Problem: neither end's port-id tells us the *service name*. The
|
||||||
|
`laddr`/`raddr` are used for logging, `Channel.raddr`,
|
||||||
|
`Server._peers` keying-adjacent repr, and `maddr`. Design:
|
||||||
|
|
||||||
|
- the **connecting** side knows the destaddr it dialled →
|
||||||
|
`connect_to()` overrides `_raddr` after construction with the
|
||||||
|
known-good `TIPCAddress`, exactly as
|
||||||
|
`MsgpackUDSStream.connect_to()` does for the peer-pid case
|
||||||
|
(`_uds.py:539-543`).
|
||||||
|
- the **accepting** side does not know the peer's service name
|
||||||
|
from the socket. Two honest options:
|
||||||
|
- **(a) accept it: `raddr` carries only `(node, ref)`** via
|
||||||
|
`maybe_node`/`maybe_ref`, `_stype/_instance` set to a
|
||||||
|
sentinel `-1`, and `__repr__` renders
|
||||||
|
`TIPCAddress[<peer-node:0x...>:<ref>]`. The `Aid` from the
|
||||||
|
handshake already gives us the peer's logical identity, so
|
||||||
|
nothing in the runtime actually *needs* the peer's service
|
||||||
|
name. **Recommended.**
|
||||||
|
- (b) piggyback the peer's own bound name in the handshake.
|
||||||
|
Rejected for this PR: touches `Aid`/msg-spec.
|
||||||
|
- `laddr` on the accepting side: the `Endpoint` knows its own
|
||||||
|
`addr`; but `get_stream_addrs()` is a `@classmethod` with only
|
||||||
|
the stream. Use `TIPC_ADDR_ID` for `laddr` too and let
|
||||||
|
`Endpoint.peer_tpts` keying (which is by *peer* addr) still
|
||||||
|
work. Verify nothing asserts `laddr == ep.addr` — grep for
|
||||||
|
`.laddr` uses before committing (`_server.py`'s
|
||||||
|
`con_status` logging, `Channel.pformat()`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Multiaddr representation
|
||||||
|
|
||||||
|
There is no `/tipc` in the multiaddr protocol table. Interim
|
||||||
|
grammar, mirroring how `uds` maps to the spec-legal `/unix`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/tipc/<stype>/<instance> # scope implied = cluster
|
||||||
|
/tipc/<stype>/<instance>/<scope> # explicit
|
||||||
|
```
|
||||||
|
|
||||||
|
- `_tpt_proto_to_maddr['tipc'] = 'tipc'` and a `mk_maddr()`
|
||||||
|
`case 'tipc':` building the above.
|
||||||
|
- `parse_maddr()` gets `case ['tipc']:` — but note
|
||||||
|
`py-multiaddr` will reject an unregistered protocol name
|
||||||
|
outright, so this **requires an upstream registration** (same
|
||||||
|
track as the `wg` work, gh #483 /
|
||||||
|
multiformats/py-multiaddr#107). Until that lands:
|
||||||
|
- `MsgpackTIPCStream.maddr` returns the **`str`** form (the
|
||||||
|
`MsgTransport.maddr` return type is already
|
||||||
|
`Multiaddr|str`, and `MsgpackUDSStream.maddr` already
|
||||||
|
exercises the `str` branch), and
|
||||||
|
- `parse_maddr()` special-cases the `/tipc/` prefix *before*
|
||||||
|
handing the string to `Multiaddr()`.
|
||||||
|
Document this as the reason gh #443's "standardize on
|
||||||
|
returning `Multiaddr` everywhere" item stays blocked.
|
||||||
|
|
||||||
|
Propose `/tipc/` upstream as: name `tipc`, code TBD, size
|
||||||
|
variable, value `<stype>:<instance>:<scope>` — or as three
|
||||||
|
composed protos. Prefer *one* proto with a structured value so
|
||||||
|
the maddr stays 2-segment like `/unix/...`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Discovery: the actually-interesting part
|
||||||
|
|
||||||
|
Two independently-shippable layers. **Layer A is in scope for
|
||||||
|
the first PR; layer B is a fast-follow.**
|
||||||
|
|
||||||
|
### 5.1 Layer A — "discovery by bind" (free)
|
||||||
|
|
||||||
|
Because `bind(TIPC_ADDR_NAMESEQ)` publishes and
|
||||||
|
`connect(TIPC_ADDR_NAME)` resolves, a `tractor` tree whose
|
||||||
|
`registry_addrs` are TIPC service names needs **no registrar
|
||||||
|
liveness at all** for the connect path: `find_actor()`'s
|
||||||
|
"connect to the registrar and ask" becomes "connect to the
|
||||||
|
service name directly". Concretely:
|
||||||
|
|
||||||
|
- `tractor.discovery._api.find_actor()` etc. keep working
|
||||||
|
unchanged (they go through the registrar), *and*
|
||||||
|
- a new, TIPC-only fast path becomes possible: derive an actor's
|
||||||
|
service name from its `(name, uuid)` and dial it without any
|
||||||
|
registrar hop.
|
||||||
|
|
||||||
|
Do **not** build the fast path in PR 1. Instead, prove the
|
||||||
|
property with a test (§7.4) and file the follow-up: it changes
|
||||||
|
`discovery` semantics (name→instance derivation must be a
|
||||||
|
documented, stable, cross-language-able hash) and deserves its
|
||||||
|
own design.
|
||||||
|
|
||||||
|
### 5.2 Layer B — the topology service (`TIPC_TOP_SRV`)
|
||||||
|
|
||||||
|
This is what makes #378's "end game cluster proto" claim real:
|
||||||
|
a *subscription* to name-table events, i.e. push-based
|
||||||
|
`register`/`deregister` for free, replacing the registrar's
|
||||||
|
polled `find_actor()`.
|
||||||
|
|
||||||
|
Mechanics (verify each field against
|
||||||
|
`linux/include/uapi/linux/tipc.h` + `net/tipc/topsrv.c` at
|
||||||
|
implementation time — the struct layout below is from the uapi
|
||||||
|
header and the byte-order caveat is real):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# SOCK_SEQPACKET connected to the topology server
|
||||||
|
sock = trio.socket.socket(AF_TIPC, SOCK_SEQPACKET)
|
||||||
|
await sock.connect((
|
||||||
|
socket.TIPC_ADDR_NAME,
|
||||||
|
socket.TIPC_TOP_SRV, # == 1
|
||||||
|
socket.TIPC_TOP_SRV,
|
||||||
|
0,
|
||||||
|
))
|
||||||
|
|
||||||
|
# struct tipc_subscr {
|
||||||
|
# struct tipc_name_seq seq; /* 3 * __u32: type, lower, upper */
|
||||||
|
# __u32 timeout; /* TIPC_WAIT_FOREVER == ~0 */
|
||||||
|
# __u32 filter; /* TIPC_SUB_{PORTS,SERVICE,CANCEL} */
|
||||||
|
# char usr_handle[8];
|
||||||
|
# } /* == 28 bytes */
|
||||||
|
_SUBSCR_FMT: str = '=IIIII8s' # ⚠ 5*I is 20 -> use '=5I8s'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **byte order**: the topology server historically accepts both
|
||||||
|
host and swapped order and auto-detects; modern kernels are
|
||||||
|
strict-ish. Pack native (`'='`) first, and if the server
|
||||||
|
closes the connection immediately, retry with `'>'`. Encode
|
||||||
|
that as a one-time probe helper
|
||||||
|
`_detect_topsrv_endianness()` cached at module level — and
|
||||||
|
put a `# ?TODO` pointing at `net/tipc/topsrv.c` for someone
|
||||||
|
to make it deterministic.
|
||||||
|
- **events**: `struct tipc_event` is `event: u32`,
|
||||||
|
`found_lower: u32`, `found_upper: u32`,
|
||||||
|
`port: {ref: u32, node: u32}`, then the 28-byte subscription
|
||||||
|
echo → 40 bytes. `event ∈ {TIPC_PUBLISHED, TIPC_WITHDRAWN,
|
||||||
|
TIPC_SUBSCR_TIMEOUT}`.
|
||||||
|
- **trio shape** — this is where the "nearly-functional,
|
||||||
|
modern-async" style pays off; expose it as an `@acm` yielding
|
||||||
|
a `trio` receive-channel of typed events, *not* a class:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@acm
|
||||||
|
async def open_topology_events(
|
||||||
|
stype: int = TRACTOR_STYPE,
|
||||||
|
lower: int = 0,
|
||||||
|
upper: int = 0xFFFFFFFF,
|
||||||
|
filter: int = TIPC_SUB_SERVICE,
|
||||||
|
timeout: int = TIPC_WAIT_FOREVER,
|
||||||
|
buf_size: int = 64,
|
||||||
|
) -> AsyncGenerator[
|
||||||
|
trio.MemoryReceiveChannel[TIPCNameEvent],
|
||||||
|
None,
|
||||||
|
]:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
with `TIPCNameEvent(msgspec.Struct, frozen=True)` fields
|
||||||
|
`kind: Literal['published','withdrawn','timeout']`,
|
||||||
|
`addr: TIPCAddress`, `node: int`, `ref: int`. One
|
||||||
|
`trio.lowlevel`-free implementation: a nursery-spawned reader
|
||||||
|
task doing `await sock.recv(40)` in a loop and
|
||||||
|
`send_nowait()`ing decoded events, with the `@acm` closing the
|
||||||
|
socket on exit → reader gets `ClosedResourceError` → cancel
|
||||||
|
scope collapses. Standard `tractor` `@acm` discipline.
|
||||||
|
- **consumer**: `tractor/discovery/_registry.py` gains an
|
||||||
|
optional "watch" mode so a registrar (or any actor) can keep
|
||||||
|
a live view of the actor set without polling. Sketch the
|
||||||
|
integration in the follow-up issue; do not wire it in PR 1.
|
||||||
|
- **`SOCK_SEQPACKET` is fine here** because this socket never
|
||||||
|
goes through `MsgpackTransport` — it's a plain trio socket
|
||||||
|
used with `recv()`. The contract's "`SOCK_STREAM` only"
|
||||||
|
constraint applies to `MsgTransport` streams, not to this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Commit sequencing (each independently reviewable + green)
|
||||||
|
|
||||||
|
1. `_server.py`: add `Address.rebind_from_sockname:
|
||||||
|
ClassVar[bool]`, gate the `getsockname()` reconciliation on
|
||||||
|
it, `True` for tcp/uds. Test: tcp `port=0` unchanged.
|
||||||
|
2. `tractor/ipc/_tipc.py`: `TIPCAddress` + `is_tipc_available()`
|
||||||
|
predicate + `start_listener()`. No transport yet.
|
||||||
|
Tests: address round-trip (`unwrap`/`from_addr`/`wrap_address`),
|
||||||
|
`get_random()` uniqueness, bind/listen + `SO_ACCEPTCONN`
|
||||||
|
tolerance, `EAFNOSUPPORT` → actionable `ConnectionError`.
|
||||||
|
3. `MsgpackTIPCStream` + `connect_to()` + `get_stream_addrs()`.
|
||||||
|
Test: two `trio` tasks in one proc exchange a msg over
|
||||||
|
`Msgpack` framing (no `tractor` runtime).
|
||||||
|
4. registration tables (contract §2 items 1-6, 9) +
|
||||||
|
`pyproject.toml` mark/extra. Test: full suite under
|
||||||
|
`--tpt-proto tipc` (§7.3).
|
||||||
|
5. maddr support (`str` form + prefix special-case) + docs.
|
||||||
|
6. `open_topology_events()` @acm + its tests (layer B).
|
||||||
|
7. docs page + `docs/` example.
|
||||||
|
|
||||||
|
Per project convention, a reproducing/guard test lands in its
|
||||||
|
own commit **before** the fix it guards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Testing
|
||||||
|
|
||||||
|
### 7.1 the capability predicate (in `_tipc.py`, public)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def is_tipc_available() -> bool:
|
||||||
|
'''
|
||||||
|
True iff this kernel can create an `AF_TIPC` socket, i.e.
|
||||||
|
the `tipc` module is loaded.
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
socket.socket(socket.AF_TIPC, socket.SOCK_STREAM).close()
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
Cache it in a module global (it can't change without a
|
||||||
|
`modprobe`, and a cold call costs a syscall). Pure predicate, no
|
||||||
|
side effects, no logging.
|
||||||
|
|
||||||
|
### 7.2 gating
|
||||||
|
|
||||||
|
- `pytest.mark.tipc` registered in `pyproject.toml`.
|
||||||
|
- module-level
|
||||||
|
`pytestmark = pytest.mark.skipif(not is_tipc_available(),
|
||||||
|
reason='`tipc` kernel module not loaded (`modprobe tipc`)')`
|
||||||
|
in `tests/ipc/test_tipc.py`.
|
||||||
|
- `--tpt-proto tipc` with no module must fail **loudly and
|
||||||
|
early** with the actionable message, not with 400 confusing
|
||||||
|
timeouts. Add the check to the `tpt_protos` fixture's existing
|
||||||
|
per-proto validation loop (`_testing/pytest.py:795`): if the
|
||||||
|
chosen `Address` type exposes an `is_available()`-style
|
||||||
|
classmethod, call it and `pytest.fail()` with its reason.
|
||||||
|
Generalize (don't special-case tipc) — plans 02/03 need the
|
||||||
|
same hook.
|
||||||
|
|
||||||
|
### 7.3 CI
|
||||||
|
|
||||||
|
- add a job matrix entry `--tpt-proto tipc` that runs
|
||||||
|
`sudo modprobe tipc` in a `before` step. GH's
|
||||||
|
`ubuntu-latest` runners do allow `modprobe tipc` (the module
|
||||||
|
ships with the standard Ubuntu kernel package); verify in a
|
||||||
|
throwaway workflow before wiring the matrix. If it turns out
|
||||||
|
to be unavailable, fall back to a container job with
|
||||||
|
`--privileged`/`--cap-add NET_ADMIN`, and mark the job
|
||||||
|
`continue-on-error` until it's proven stable.
|
||||||
|
- cross-node TIPC (bearer) cannot be CI'd; cover it with a
|
||||||
|
documented manual smoke test in the docs page, in the style
|
||||||
|
of gh #482's LAN examples.
|
||||||
|
|
||||||
|
### 7.4 backend-specific tests worth writing
|
||||||
|
|
||||||
|
- **name-publication is discovery**: bind a listener on
|
||||||
|
`(stype, inst)`, then from a second task `connect()` by name
|
||||||
|
and assert it lands — *without* any `tractor` registrar.
|
||||||
|
- **`get_random()` collision resistance**: 10k `get_random()`
|
||||||
|
calls with no live runtime → 10k distinct `_instance`s.
|
||||||
|
(This is the silent-crosstalk risk from §2.3; if the 4-byte
|
||||||
|
digest ever collides in this test, escalate to §9.)
|
||||||
|
- **round-robin surprise**: two listeners bound to the *same*
|
||||||
|
`(stype, inst)` both succeed (TIPC allows it) and connects
|
||||||
|
distribute. Assert the observed behaviour and reference it
|
||||||
|
from the `get_random()` docstring so the next reader knows
|
||||||
|
why the hash matters.
|
||||||
|
- **scope isolation**: a `TIPC_NODE_SCOPE` bind is not visible
|
||||||
|
to a cluster-scope lookup from another node (manual/marked).
|
||||||
|
- **importance opt** round-trips via `getsockopt`.
|
||||||
|
- **graceful + abrupt close** produce `TransportClosed` with the
|
||||||
|
same `loglevel` classification as tcp/uds — i.e. re-run the
|
||||||
|
relevant `tests/ipc/test_each_tpt.py` cases parametrized over
|
||||||
|
the new proto rather than writing new ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Deployment / docs deliverable
|
||||||
|
|
||||||
|
A `docs/` page (and/or an `examples/` script) covering:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# single host, node-scope only
|
||||||
|
sudo modprobe tipc
|
||||||
|
tipc node get addr
|
||||||
|
|
||||||
|
# multi-host over ethernet (pairs beautifully with plan 03's wg)
|
||||||
|
sudo tipc bearer enable media eth device eth0
|
||||||
|
# ...or over UDP when L2 isn't available:
|
||||||
|
sudo tipc bearer enable media udp name uc localip 10.0.11.1
|
||||||
|
tipc link list
|
||||||
|
tipc nametable show # <- see tractor's published services!
|
||||||
|
```
|
||||||
|
|
||||||
|
`tipc nametable show` displaying live `tractor` actors is the
|
||||||
|
single best demo this backend has; lead with it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Known risks + escalations
|
||||||
|
|
||||||
|
| risk | mitigation |
|
||||||
|
| --- | --- |
|
||||||
|
| `_instance` hash collision → silent crosstalk (two actors share a service name, TIPC round-robins connects between them) | §7.4 test; if it bites, add a post-bind verification handshake, or bump to a 6-byte digest folded into `(stype_low, instance)` |
|
||||||
|
| kernel/module unavailability everywhere (dev boxes, macOS, CI) | hard gating (§7.2); TIPC is explicitly an *opt-in cluster* transport, never a default |
|
||||||
|
| `getsockname()` returns port-id not name | the `rebind_from_sockname` opt-out (§3.2), landed first |
|
||||||
|
| unregistered `/tipc` multiaddr proto | `str` maddr fallback (§4) + upstream track gh #483 |
|
||||||
|
| stale docs (#378 notes tipc.io docs may be out of date) | treat `include/uapi/linux/tipc.h` + `net/tipc/` as the only normative source; cite file+symbol in code comments |
|
||||||
|
| `SOCK_SEQPACKET` topology framing byte-order | probe helper + `?TODO` (§5.2) |
|
||||||
|
|
||||||
|
## 10. Follow-up issue seeds
|
||||||
|
|
||||||
|
- **register `/tipc` in the multiaddr spec**, mirroring the `wg`
|
||||||
|
track (multiformats/py-multiaddr#107/#108 + gh #483). Same
|
||||||
|
shape of work: propose the proto + code, land a codec in
|
||||||
|
`py-multiaddr`, then drop our `str`-maddr fallback (§4). Worth
|
||||||
|
filing *alongside* the `wg` spec-submission issue so both
|
||||||
|
proposals go up together rather than as one-offs.
|
||||||
|
- registrar-less discovery fast path via name derivation (§5.1)
|
||||||
|
- `TIPC_TOP_SRV`-driven push registry in
|
||||||
|
`discovery/_registry.py` (§5.2)
|
||||||
|
- `TIPC_IMPORTANCE` for the parent<->child lifetime channel
|
||||||
|
(§3.3) — genuinely novel supervision QoS, no other backend
|
||||||
|
can do it
|
||||||
|
- TIPC multicast / group messaging as a *broadcast* transport
|
||||||
|
for `tractor.trionics` fan-out (explicitly not `MsgTransport`)
|
||||||
|
- dual-link resiliency / multi-homing (#378's "hybrid dual link")
|
||||||
|
once bearers are scripted in the docs
|
||||||
|
|
@ -0,0 +1,566 @@
|
||||||
|
# Plan 02 — QUIC backend via `iroh` FFI, uniffi-async rewritten onto `trio`
|
||||||
|
|
||||||
|
Tracks gh [#353]. Prereq reading:
|
||||||
|
[`00_shared_backend_contract.md`](./00_shared_backend_contract.md).
|
||||||
|
|
||||||
|
**Thesis**: the value of `iroh` over "just QUIC" is
|
||||||
|
`NodeId`-addressed, NAT-traversing, relay-fallback endpoints —
|
||||||
|
i.e. a `tractor` actor tree that spans hosts *without* a
|
||||||
|
reachable listening socket. The cost is that `iroh`'s python
|
||||||
|
surface is `uniffi`-generated **asyncio** and its listener is not
|
||||||
|
a socket. This plan spends its complexity budget in exactly two
|
||||||
|
places: a `trio`-native uniffi future bridge, and a
|
||||||
|
`trio.abc.Listener`/`Stream` adapter pair. Everything else is
|
||||||
|
contract boilerplate.
|
||||||
|
|
||||||
|
[#353]: https://github.com/goodboy/tractor/issues/353
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Library selection (decided, with the rejected alternatives)
|
||||||
|
|
||||||
|
**Chosen: `iroh` (PyPI, from `n0-computer/iroh-ffi`), pinned to
|
||||||
|
a single minor.** The `iroh` python package is a `uniffi`
|
||||||
|
binding over the rust `iroh` crate (QUIC via `quinn`/`noq`).
|
||||||
|
|
||||||
|
Rejected, and why — record these so the next implementer doesn't
|
||||||
|
relitigate:
|
||||||
|
|
||||||
|
- **`aioquic`** (sans-io + asyncio): genuinely trio-portable
|
||||||
|
(`hypercorn` already pairs its sans-io core with a trio UDP
|
||||||
|
server, see the links in #353) and dependency-light. But it
|
||||||
|
gives us *only* QUIC — no NodeId identity, no hole punching,
|
||||||
|
no relay. We'd be reimplementing iroh's whole reason for
|
||||||
|
existing. **Keep as the documented fallback** if the FFI
|
||||||
|
bridge (§2) proves unmaintainable; the `MsgTransport` and
|
||||||
|
`Listener` adapters from §3 are ~90% reusable against an
|
||||||
|
`aioquic` core, which is a deliberate design property of this
|
||||||
|
plan.
|
||||||
|
- **`quiche` / `quinn` via a hand-rolled PyO3 ext**: strictly
|
||||||
|
more work than reusing `iroh-ffi`, and puts us in the
|
||||||
|
build-wheels business.
|
||||||
|
- **`trio-asyncio`**: viable *shortcut* to run the asyncio-shaped
|
||||||
|
bindings under trio, and `tractor` already ships
|
||||||
|
infected-asyncio machinery (`tractor.to_asyncio`,
|
||||||
|
`tests/test_infected_asyncio.py`). Rejected as the *primary*
|
||||||
|
design because it makes every IPC send/recv cross a
|
||||||
|
loop-boundary shim in the hot path, and because #353 asks
|
||||||
|
explicitly for the asyncio support to be "rewritten for trio".
|
||||||
|
**But**: build it first as the throwaway spike (§6 step 0) to
|
||||||
|
de-risk the iroh API surface before writing the bridge.
|
||||||
|
|
||||||
|
Version pinning: `iroh` moves fast and has had breaking
|
||||||
|
API renames across minors. Pin `iroh>=X.Y,<X.Y+1` in a `quic`
|
||||||
|
extra, and **write down the exact resolved version + the
|
||||||
|
generated `iroh/_uniffi*` module layout** in the module
|
||||||
|
docstring, because §2 depends on generated-code internals.
|
||||||
|
|
||||||
|
**Step 0 of implementation is an API-truth pass**: install the
|
||||||
|
pinned `iroh`, `python -c "import iroh; help(iroh)"`, and record
|
||||||
|
in this doc's §1.1 the real names of: endpoint builder, secret
|
||||||
|
key type, `connect`/`accept`, bi-stream open/accept, the
|
||||||
|
send/recv methods and their exact signatures/return types, and
|
||||||
|
whether they're `async def`. Everything below uses *provisional*
|
||||||
|
names and must be reconciled. Do not skip this; do not guess
|
||||||
|
from memory.
|
||||||
|
|
||||||
|
### 1.1 API-truth table (fill in during step 0)
|
||||||
|
|
||||||
|
| concept | provisional name | actual (fill in) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| secret key | `iroh.SecretKey.generate()` | |
|
||||||
|
| endpoint builder | `iroh.Endpoint.builder(...).bind()` | |
|
||||||
|
| node id | `endpoint.node_id() -> str` | |
|
||||||
|
| node addr (relay + direct) | `iroh.NodeAddr` | |
|
||||||
|
| dial | `await endpoint.connect(node_addr, alpn)` | |
|
||||||
|
| accept conn | `await endpoint.accept()` | |
|
||||||
|
| open bi-stream | `await conn.open_bi()` | |
|
||||||
|
| accept bi-stream | `await conn.accept_bi()` | |
|
||||||
|
| send | `await send_stream.write_all(b)` | |
|
||||||
|
| recv | `await recv_stream.read(n) -> bytes\|None` | |
|
||||||
|
| half-close | `await send_stream.finish()` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The `trio`-native uniffi future bridge (`tractor/ipc/_uniffi_trio.py`)
|
||||||
|
|
||||||
|
### 2.1 what uniffi actually generates
|
||||||
|
|
||||||
|
`uniffi`'s async support does not use asyncio *semantically* —
|
||||||
|
it uses asyncio only as the *executor* for a poll loop. The
|
||||||
|
generated python for an `async fn` is, in shape:
|
||||||
|
|
||||||
|
1. call `_uniffi_..._<method>(...)` → returns an opaque
|
||||||
|
`RustFuture` handle (a `void*`/`u64`).
|
||||||
|
2. loop: call
|
||||||
|
`ffi_..._rust_future_poll_<T>(handle, callback, callback_data)`.
|
||||||
|
The callback is a C-ABI fn pointer invoked **from an
|
||||||
|
arbitrary rust thread** with a poll-result code
|
||||||
|
(`READY`/`MAYBE_READY`).
|
||||||
|
3. the generated glue's callback resolves an
|
||||||
|
`asyncio.Future` via `loop.call_soon_threadsafe(...)`; the
|
||||||
|
coroutine awaits it, then re-polls.
|
||||||
|
4. on ready: `ffi_..._rust_future_complete_<T>(handle,
|
||||||
|
&call_status)` → the value; then
|
||||||
|
`ffi_..._rust_future_free_<T>(handle)`.
|
||||||
|
|
||||||
|
**The asyncio dependency is confined to step 3.** That is the
|
||||||
|
whole insight: the bridge is ~40 lines.
|
||||||
|
|
||||||
|
### 2.2 the trio version
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def await_rust_future(
|
||||||
|
poll: Callable, # ffi_..._rust_future_poll_<T>
|
||||||
|
complete: Callable, # ffi_..._rust_future_complete_<T>
|
||||||
|
free: Callable, # ffi_..._rust_future_free_<T>
|
||||||
|
handle: int,
|
||||||
|
lift: Callable[[Any], Any],
|
||||||
|
) -> Any:
|
||||||
|
'''
|
||||||
|
Drive a `uniffi` rust-future to completion on the current
|
||||||
|
`trio` task, bridging rust-thread wakeups via
|
||||||
|
`TrioToken.run_sync_soon()`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
token = trio.lowlevel.current_trio_token()
|
||||||
|
while True:
|
||||||
|
wake = trio.Event()
|
||||||
|
# NOTE, invoked from a *rust* thread!
|
||||||
|
def _cb(_data, poll_code):
|
||||||
|
token.run_sync_soon(wake.set)
|
||||||
|
|
||||||
|
cb = _UNIFFI_FUTURE_CALLBACK(_cb) # keep a strong ref!
|
||||||
|
poll(handle, cb, 0)
|
||||||
|
await wake.wait()
|
||||||
|
if <poll_code was READY>:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
status = _UniffiRustCallStatus.default()
|
||||||
|
res = complete(handle, status)
|
||||||
|
_uniffi_check_call_status(status) # reuse generated helper
|
||||||
|
return lift(res)
|
||||||
|
finally:
|
||||||
|
free(handle)
|
||||||
|
```
|
||||||
|
|
||||||
|
Critical details, each a real bug if missed:
|
||||||
|
|
||||||
|
- **`token.run_sync_soon()` is the only trio API callable from a
|
||||||
|
foreign thread**, and it is documented as such. Use it; do
|
||||||
|
*not* use `trio.from_thread.run_sync` (requires a trio thread
|
||||||
|
context) and do not touch the `Event` directly from the
|
||||||
|
callback.
|
||||||
|
- **the poll code must reach the trio side.** Capture it in a
|
||||||
|
`nonlocal`/1-slot list written by the callback *before*
|
||||||
|
`run_sync_soon`, since the callback owns the value. Handle
|
||||||
|
`MAYBE_READY` by re-polling (the loop above does).
|
||||||
|
- **keep the `ctypes` callback object alive** across the await —
|
||||||
|
a GC'd `CFUNCTYPE` trampoline is a segfault. Bind it to a
|
||||||
|
local *and* make sure the local outlives the `poll()` call
|
||||||
|
window.
|
||||||
|
- **cancellation.** `await wake.wait()` is a trio checkpoint, so
|
||||||
|
a `Cancelled` can fire while rust still owns the future. On
|
||||||
|
cancel we must still `free(handle)` — and per uniffi, the
|
||||||
|
correct sequence is to call the generated
|
||||||
|
`ffi_..._rust_future_cancel_<T>(handle)` then continue
|
||||||
|
polling to completion before `free`. Wrap the whole thing so
|
||||||
|
the cancel path does:
|
||||||
|
`with trio.CancelScope(shield=True): cancel(handle); <drain
|
||||||
|
poll loop>; free(handle)`. **Bounded** shield (add a
|
||||||
|
`trio.move_on_after()` with a module-level constant) so a
|
||||||
|
wedged rust future can't make an actor un-cancellable —
|
||||||
|
`tractor` is SC-first and an unbounded shield here would
|
||||||
|
violate that.
|
||||||
|
- **`trio.lowlevel.current_trio_token()`** must be captured on
|
||||||
|
the trio side (not in the callback).
|
||||||
|
|
||||||
|
### 2.3 how to apply it to the generated bindings
|
||||||
|
|
||||||
|
Do **not** fork/vendor the generated `iroh` python. Instead ship
|
||||||
|
a *narrow* re-dispatch shim:
|
||||||
|
|
||||||
|
- write `tractor/ipc/_uniffi_trio.py` with `await_rust_future()`
|
||||||
|
plus a `@cm patch_uniffi_for_trio()` that monkey-patches the
|
||||||
|
generated module's single async-driver entrypoint (in current
|
||||||
|
uniffi that's `_uniffi_rust_call_async` / `_rust_call_async`,
|
||||||
|
one function) to the trio implementation.
|
||||||
|
- verify at import time that the expected symbol exists and
|
||||||
|
raise a clear, actionable error naming the pinned `iroh`
|
||||||
|
version if not. A silent fallback to asyncio would be a
|
||||||
|
nightmare to debug.
|
||||||
|
- **plan for this to break on `iroh`/`uniffi` upgrades.** Mitigate
|
||||||
|
with (a) a unit test that drives one trivial `iroh` async call
|
||||||
|
under bare `trio.run()` and asserts no event loop was ever
|
||||||
|
created (`asyncio.get_event_loop_policy()` untouched /
|
||||||
|
`asyncio._get_running_loop() is None`), and (b) a docstring
|
||||||
|
pointing at the uniffi codegen template this mirrors.
|
||||||
|
|
||||||
|
If step 0 reveals the generated code is *structurally* hostile
|
||||||
|
to this (e.g. `asyncio` imported and used at module scope for
|
||||||
|
more than the driver), fall back to option (b): run iroh under
|
||||||
|
`tractor.to_asyncio` infected mode and open the follow-up to
|
||||||
|
revisit. Say so in the PR rather than fighting it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mapping QUIC onto `MsgTransport`
|
||||||
|
|
||||||
|
### 3.1 the layering decision
|
||||||
|
|
||||||
|
QUIC natively multiplexes streams inside one connection. The
|
||||||
|
mapping that preserves *all* existing `tractor` semantics with
|
||||||
|
the least new code:
|
||||||
|
|
||||||
|
```
|
||||||
|
iroh Endpoint == one per actor (process) -> the "listener"
|
||||||
|
iroh Connection == one per peer actor -> pooled
|
||||||
|
iroh bi-stream == one `Channel`/`MsgTransport` -> 1:1
|
||||||
|
```
|
||||||
|
|
||||||
|
- keep the 4-byte `<I` length-prefix framing **unchanged**. It's
|
||||||
|
redundant-ish over a QUIC stream but it means
|
||||||
|
`MsgpackTransport` is reused verbatim, and framing is cheap.
|
||||||
|
Revisit only after it works.
|
||||||
|
- **one-task-per-stream** falls out naturally, which is exactly
|
||||||
|
the #353 note about QUIC sub-stream QoS/cancellation fitting
|
||||||
|
`trio`.
|
||||||
|
- `layer_key: int = 4` still (QUIC is L4-ish); note in a comment
|
||||||
|
that this backend is really 4+security+multiplex.
|
||||||
|
|
||||||
|
**Connection pooling** is the one place we add state the other
|
||||||
|
backends don't have: dialing the same peer twice should reuse
|
||||||
|
the `Connection` and open a second bi-stream. Implement as a
|
||||||
|
module-level `dict[NodeId, Connection]` guarded by a
|
||||||
|
`trio.Lock`... **no** — that's a per-process cache with
|
||||||
|
lifetime/teardown hazards. Instead reuse the codebase's existing
|
||||||
|
idiom: `tractor.trionics.maybe_open_context()` keyed on the
|
||||||
|
node-id, which already solves exactly this (one-cached-resource-
|
||||||
|
per-key, refcounted, teardown-on-last-exit) and whose teardown
|
||||||
|
semantics were just hardened (gh #488). Use it; do not hand-roll
|
||||||
|
a cache. Anything concurrency-subtle here should get the
|
||||||
|
`conc-anal` skill run over it.
|
||||||
|
|
||||||
|
### 3.2 `IrohAddress`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class IrohAddress(
|
||||||
|
msgspec.Struct,
|
||||||
|
frozen=True,
|
||||||
|
):
|
||||||
|
_node_id: str # 32B ed25519 pubkey, hex or z32
|
||||||
|
_alpn: str = 'tractor/0' # the bindspace!
|
||||||
|
# optional dial hints; NOT part of identity
|
||||||
|
maybe_relay_url: str|None = None
|
||||||
|
maybe_direct_addrs: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
proto_key: ClassVar[str] = 'iroh' # ?or 'quic'; see §3.2.1
|
||||||
|
unwrapped_type: ClassVar[type] = tuple[str, str]
|
||||||
|
def_bindspace: ClassVar[str] = 'tractor/0'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`.unwrap() -> (node_id_str, alpn_str)`** — a `(str, str)`
|
||||||
|
tuple, which is *unambiguously distinct* from
|
||||||
|
`TCPAddress`'s `(str, int)`. But careful:
|
||||||
|
`wrap_address()`'s UDS case is
|
||||||
|
`case (_, filename) if type(filename) is str` — which
|
||||||
|
**already catches `(str, str)`**. So the iroh `case` MUST be
|
||||||
|
ordered *before* the UDS case and guarded, e.g.
|
||||||
|
`case (str() as nid, str() as alpn) if _is_node_id(nid):`
|
||||||
|
with `_is_node_id()` a cheap length+alphabet check. Add a
|
||||||
|
regression test asserting a UDS `(dir, filename)` pair still
|
||||||
|
wraps to `UDSAddress` — this is the exact "wrong transport
|
||||||
|
loaded" hazard `_addr.py:214` warns about.
|
||||||
|
- `.bindspace` → `self._alpn`. This is the honest analogue:
|
||||||
|
the ALPN is the set of endpoints willing to talk to you, and
|
||||||
|
two `tractor` deployments sharing an iroh network are
|
||||||
|
separated by ALPN exactly as two UDS deployments are
|
||||||
|
separated by directory. Include a `tractor` version/proto
|
||||||
|
epoch in the default ALPN so incompatible runtimes can't
|
||||||
|
handshake.
|
||||||
|
- `.is_valid` → node-id parses, alpn non-empty.
|
||||||
|
- **`get_root()` is the hard one.** There is no
|
||||||
|
well-known-port analogue: an iroh node id is a *keypair*, so
|
||||||
|
"the host's default registrar addr" requires a *persisted
|
||||||
|
secret key*. Design:
|
||||||
|
- the root/registrar's secret key lives at
|
||||||
|
`get_rt_dir() / 'iroh_registrar.key'` (0600), created on
|
||||||
|
first use.
|
||||||
|
- `get_root()` must stay **pure and import-time-safe**
|
||||||
|
(contract §2.3: `_default_lo_addrs` is built at import!).
|
||||||
|
So `get_root()` *reads* the key file if present and
|
||||||
|
otherwise returns an `IrohAddress` with
|
||||||
|
`_node_id=''`/sentinel, and the **generation** happens in
|
||||||
|
an explicit sibling — `ensure_registrar_key() ->
|
||||||
|
IrohAddress` — called from the listen path. Pure getter,
|
||||||
|
explicit setter; do not smuggle key generation into
|
||||||
|
`get_root()`.
|
||||||
|
- this almost certainly means `_default_lo_addrs` must become
|
||||||
|
lazy for this backend. **Land that refactor as its own prep
|
||||||
|
commit** (a `default_lo_addrs()` that computes per-call
|
||||||
|
instead of the import-time dict) — it also unblocks plan
|
||||||
|
03's netns-scoped defaults.
|
||||||
|
- `get_random()`: generate a fresh `SecretKey` per subactor and
|
||||||
|
return its node-id. Note this runs post-fork pre-listen
|
||||||
|
(contract §4) and costs an ed25519 keygen (~µs, fine). The
|
||||||
|
*secret* can't live in a frozen `Address`, so it must be
|
||||||
|
stashed where the listen path can find it: a module-level
|
||||||
|
`dict[node_id, SecretKey]` populated by `get_random()` and
|
||||||
|
consumed+popped by `start_listener()`. Ugly but honest;
|
||||||
|
document it and note the alternative (thread the key through
|
||||||
|
`Endpoint`) as a follow-up.
|
||||||
|
|
||||||
|
#### 3.2.1 `proto_key`: `'iroh'` vs `'quic'`
|
||||||
|
|
||||||
|
Use **`'quic'`** for the `proto_key`/`--tpt-proto` name and
|
||||||
|
name the module `_quic.py`, with `iroh` as the *implementation*.
|
||||||
|
Rationale: it keeps the door open for the `aioquic` fallback
|
||||||
|
(§1) without a user-visible rename, and it matches how `uds` is
|
||||||
|
a proto name rather than a lib name. Put `iroh`-specific bits
|
||||||
|
behind an internal `_iroh` submodule if the file gets big.
|
||||||
|
|
||||||
|
### 3.3 the `trio.abc` adapters — where the real work is
|
||||||
|
|
||||||
|
Contract §3 says a non-socket backend needs three upstream
|
||||||
|
generalizations. Land them **as a prep PR, before any iroh
|
||||||
|
code**, so they can be reviewed on their own merits with
|
||||||
|
tcp/uds still the only backends:
|
||||||
|
|
||||||
|
1. **`Endpoint.start_listener()` must not assume
|
||||||
|
`.socket.getsockname()`.** Use the same
|
||||||
|
`Address.rebind_from_sockname: ClassVar[bool]` gate that
|
||||||
|
plan 01 §3.2 introduces — coordinate so it lands once. (If
|
||||||
|
plan 01 lands first, this is free.)
|
||||||
|
2. **`transport_from_stream()` (`_types.py:92`) must not assume
|
||||||
|
`trio.SocketStream`.** Replace the `sock.family` match with:
|
||||||
|
check `isinstance(stream, trio.SocketStream)` → existing
|
||||||
|
family match; else look for a
|
||||||
|
`stream.tpt_key: ClassVar[MsgTransportKey]` attribute on the
|
||||||
|
adapter and use it. Keeps the existing path byte-identical
|
||||||
|
and makes new stream types self-describing (a much better
|
||||||
|
shape than growing an `isinstance` ladder).
|
||||||
|
3. **type annotations**: `handle_stream_from_peer(stream:
|
||||||
|
trio.SocketStream)` → `trio.abc.Stream`; `Endpoint._listener:
|
||||||
|
SocketListener|None` → `trio.abc.Listener|None`;
|
||||||
|
`MsgTransport.stream: trio.SocketStream` →
|
||||||
|
`trio.abc.Stream`. Annotation-only, zero behaviour change.
|
||||||
|
|
||||||
|
Then the adapters:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class QuicMsgStream(trio.abc.HalfCloseableStream):
|
||||||
|
'''
|
||||||
|
A single `iroh` bi-directional QUIC stream presented as
|
||||||
|
a `trio` byte-stream so `MsgpackTransport` can frame over
|
||||||
|
it unmodified.
|
||||||
|
|
||||||
|
'''
|
||||||
|
tpt_key: ClassVar[MsgTransportKey] = ('msgpack', 'quic')
|
||||||
|
|
||||||
|
def __init__(self, conn, send, recv) -> None: ...
|
||||||
|
async def send_all(self, data: bytes) -> None: ...
|
||||||
|
async def wait_send_all_might_not_block(self) -> None: ...
|
||||||
|
async def receive_some(self, max_bytes: int|None = None) -> bytes: ...
|
||||||
|
async def send_eof(self) -> None: ...
|
||||||
|
async def aclose(self) -> None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-negotiable behaviours (each maps to a `match` case that
|
||||||
|
already exists in `_transport.py` and must keep working):
|
||||||
|
|
||||||
|
- `receive_some()` returns `b''` at clean EOF →
|
||||||
|
`MsgpackTransport._iter_packets()` sees `header == b''` and
|
||||||
|
raises `TransportClosed(loglevel='transport')`. **This is the
|
||||||
|
graceful-disconnect path the whole runtime relies on**; get it
|
||||||
|
right first.
|
||||||
|
- a reset/aborted stream → raise `trio.BrokenResourceError`.
|
||||||
|
- use after local close → raise `trio.ClosedResourceError`
|
||||||
|
(ideally with `'another task closed this fd'`-equivalent text
|
||||||
|
absent, so the `raise_on_report` branch at
|
||||||
|
`_transport.py:290` stays quiet).
|
||||||
|
- `send_all()` on a closed peer → `trio.BrokenResourceError`.
|
||||||
|
- honour `trio`'s one-task-per-direction rule: guard with
|
||||||
|
`trio._util.ConflictDetector` equivalents (or just document +
|
||||||
|
assert), because `MsgpackTransport` already serializes sends
|
||||||
|
with a `StrictFIFOLock` but recvs are single-task by
|
||||||
|
construction.
|
||||||
|
- **buffering**: if iroh's `read()` doesn't support
|
||||||
|
"read up to n", `receive_some()` must maintain an internal
|
||||||
|
leftover buffer. Note `MsgpackTransport` wraps us in
|
||||||
|
`tricycle.BufferedReceiveStream` anyway, so `receive_some()`
|
||||||
|
just needs *some* nonzero-progress contract.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class QuicListener(trio.abc.Listener):
|
||||||
|
'''
|
||||||
|
Accepts iroh `Connection`s and yields one `QuicMsgStream`
|
||||||
|
per accepted bi-stream, so `trio.serve_listeners()` spawns
|
||||||
|
one `handle_stream_from_peer()` per `Channel`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def accept(self) -> QuicMsgStream: ...
|
||||||
|
async def aclose(self) -> None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The accept-side subtlety: `trio.abc.Listener.accept()` yields
|
||||||
|
one stream per call, but iroh gives us *connections* which then
|
||||||
|
yield *streams*. So `QuicListener` needs an internal
|
||||||
|
`trio.MemoryReceiveChannel[QuicMsgStream]` fed by a background
|
||||||
|
task-pair (one task accepting connections, one per connection
|
||||||
|
accepting bi-streams). `trio.abc.Listener` has no nursery, so:
|
||||||
|
make the listener **constructed by an `@acm`** that owns the
|
||||||
|
nursery, and have `start_listener()` be that `@acm`'s driver.
|
||||||
|
|
||||||
|
⚠️ this collides with `Endpoint.start_listener()` being a plain
|
||||||
|
`async def` returning a listener. Two options:
|
||||||
|
- **(a)** hang the nursery off the `Endpoint`'s existing
|
||||||
|
`listen_tn` — `_serve_ipc_eps()` already creates `listen_tn`
|
||||||
|
and passes it into every `Endpoint` (`_server.py:1063-1074`),
|
||||||
|
and `Endpoint.listen_tn` is right there. So
|
||||||
|
`start_listener()` can `self.listen_tn.start_soon(...)` the
|
||||||
|
acceptor tasks. **Recommended**: no upstream signature change,
|
||||||
|
correct lifetime (dies with the ep group), and it's why
|
||||||
|
`listen_tn` is on the struct in the first place.
|
||||||
|
- (b) change `start_listener()` to a `@acm`. Bigger blast
|
||||||
|
radius; only if (a) proves insufficient.
|
||||||
|
|
||||||
|
Since `start_listener()` is called via
|
||||||
|
`inspect.getmodule(addr)` with only `addr=` (contract §1.3),
|
||||||
|
option (a) needs the `Endpoint` itself. Either add `ep=` to the
|
||||||
|
module-level `start_listener()` call signature (all backends
|
||||||
|
ignore it except quic → small upstream change, do it as part of
|
||||||
|
the prep PR and make it keyword-only with a default) or have
|
||||||
|
`QuicListener.accept()` lazily spawn via
|
||||||
|
`trio.lowlevel.current_task().parent_nursery` (**rejected** —
|
||||||
|
fragile, implicit). Do the explicit `ep=` kwarg.
|
||||||
|
|
||||||
|
### 3.4 `maddr`
|
||||||
|
|
||||||
|
Multiaddr already standardizes the pieces:
|
||||||
|
|
||||||
|
```
|
||||||
|
/ip4/<h>/udp/<p>/quic-v1 # direct
|
||||||
|
/ip4/<h>/udp/<p>/quic-v1/p2p/<node-id> # direct + identity
|
||||||
|
/dns/<relay-host>/tcp/443/tls/ws/p2p/<node> # relay-ish
|
||||||
|
```
|
||||||
|
|
||||||
|
- primary form: `/p2p/<node-id>` alone is a legal maddr and is
|
||||||
|
the *only* required component for iroh dialling — relay +
|
||||||
|
direct addrs are discovery hints. So `mk_maddr()` emits
|
||||||
|
`/p2p/<node_id>` and, when known, prefixes the direct
|
||||||
|
`/ip4/../udp/../quic-v1/`.
|
||||||
|
- `/p2p/` values are multihash-encoded peer ids; an iroh node-id
|
||||||
|
is a raw ed25519 key. Converting requires the identity
|
||||||
|
multihash + libp2p key protobuf wrapper. **Decide**: emit the
|
||||||
|
raw node-id under a *tractor-local* `/iroh/<node-id>` segment
|
||||||
|
(needs upstream registration, same track as `wg`/`tipc`,
|
||||||
|
gh #483) rather than pretending to be a libp2p peer-id we
|
||||||
|
can't round-trip. Return the `str` form until upstream lands
|
||||||
|
(`MsgTransport.maddr` is `Multiaddr|str`).
|
||||||
|
- this backend is the strongest argument for gh #443's
|
||||||
|
**tunnelled/composed maddr** item: `/ip4/../udp/../quic-v1/..`
|
||||||
|
*is* a composed stack. Cross-reference plan 03 §5 so the two
|
||||||
|
grammars land compatibly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Discovery integration
|
||||||
|
|
||||||
|
- iroh's node-id addressing means the `tractor` registrar can
|
||||||
|
hold `IrohAddress`es that are **reachable from anywhere** with
|
||||||
|
no port-forwarding — that is the headline feature. The
|
||||||
|
registrar itself works unchanged.
|
||||||
|
- iroh has its own discovery (DNS/pkarr/mdns). **Out of scope**;
|
||||||
|
note in the follow-up that `tractor.discovery` could
|
||||||
|
eventually delegate to it, which would be the direct analogue
|
||||||
|
of plan 01's TIPC-topology idea.
|
||||||
|
- relay servers: default to n0's public relays for the demo,
|
||||||
|
document self-hosting (docs.iroh.computer's dedicated-infra
|
||||||
|
page is linked from #353), and make the relay set a
|
||||||
|
`start_listener()` kwarg.
|
||||||
|
|
||||||
|
## 5. Security note
|
||||||
|
|
||||||
|
QUIC is TLS-1.3-always and iroh authenticates by node-id, so
|
||||||
|
this backend is the first `tractor` transport with real
|
||||||
|
transport security and peer authentication. Two things follow:
|
||||||
|
1. an **allowlist hook** — an actor should be able to reject
|
||||||
|
inbound connections from unknown node-ids *before* the
|
||||||
|
`Aid` handshake. Natural home: a predicate kwarg on
|
||||||
|
`start_listener()`, evaluated in `QuicListener`'s connection
|
||||||
|
acceptor task. Sketch it; ship it in PR 1 if cheap (it is).
|
||||||
|
2. do **not** claim any security property for the other
|
||||||
|
backends by association. `tcp`/`uds`/`tipc` remain
|
||||||
|
unauthenticated; that's what plan 03 (wg) is for.
|
||||||
|
|
||||||
|
## 6. Commit sequencing
|
||||||
|
|
||||||
|
0. **spike (throwaway, not committed)**: drive iroh under
|
||||||
|
`trio-asyncio`/`tractor.to_asyncio`, echo bytes over a
|
||||||
|
bi-stream between two procs. Fills in §1.1. Timebox it.
|
||||||
|
1. prep PR: annotation widening + `rebind_from_sockname` gate +
|
||||||
|
`transport_from_stream()` `tpt_key` dispatch + `ep=` kwarg on
|
||||||
|
`start_listener()` + lazy `default_lo_addrs()`. **No new
|
||||||
|
backend.** Full suite green on tcp *and* uds.
|
||||||
|
2. `_uniffi_trio.py` + its tests (drive one iroh async call
|
||||||
|
under bare `trio.run()`; assert no asyncio loop; assert
|
||||||
|
cancellation frees the future).
|
||||||
|
3. `QuicMsgStream` + tests against a *loopback* iroh endpoint
|
||||||
|
pair in one process (no `tractor` runtime): send/recv, clean
|
||||||
|
EOF → `b''`, reset → `BrokenResourceError`, use-after-close
|
||||||
|
→ `ClosedResourceError`.
|
||||||
|
4. `QuicListener` + `start_listener()` + `IrohAddress` +
|
||||||
|
key-file mgmt.
|
||||||
|
5. `MsgpackQuicStream(MsgpackTransport)` + `connect_to()` +
|
||||||
|
`maybe_open_context()` connection pooling.
|
||||||
|
6. registration tables + `--tpt-proto quic` + full suite.
|
||||||
|
7. maddr + docs + a two-host example (pairs with #482's format).
|
||||||
|
|
||||||
|
## 7. Testing
|
||||||
|
|
||||||
|
- capability predicate `is_quic_available()` → `iroh` importable
|
||||||
|
*and* the uniffi driver symbol present at the pinned version.
|
||||||
|
Same `pytest.fail`-early hook as plan 01 §7.2.
|
||||||
|
- **the acceptance bar is the same**: whole suite green under
|
||||||
|
`--tpt-proto quic`. Expect this to shake out real bugs in the
|
||||||
|
adapters (esp. teardown ordering and `TransportClosed`
|
||||||
|
classification) — that's the point.
|
||||||
|
- expect to need **timeout headroom**: iroh endpoint bind +
|
||||||
|
first connect (relay discovery) is orders of magnitude slower
|
||||||
|
than a UDS bind. Before touching any test deadline, rule out
|
||||||
|
the CPU-throttle false-positive (see the project's
|
||||||
|
`env_cpu_throttle_masquerades_as_regression` note); then, if
|
||||||
|
real, add a per-proto timeout multiplier to the test harness
|
||||||
|
rather than editing individual tests.
|
||||||
|
- a no-network test mode: iroh with relays disabled +
|
||||||
|
loopback direct addrs only, so CI doesn't depend on n0's
|
||||||
|
infra. **Make this the default in CI**; mark the relay tests
|
||||||
|
`pytest.mark.net` and keep them out of the default run.
|
||||||
|
- leak checks: assert every `SecretKey`/`Endpoint` is closed on
|
||||||
|
actor teardown (an `Endpoint` left open holds UDP sockets and
|
||||||
|
relay connections; a leak here shows up as hung tests, not
|
||||||
|
errors).
|
||||||
|
|
||||||
|
## 8. Risks
|
||||||
|
|
||||||
|
| risk | mitigation |
|
||||||
|
| --- | --- |
|
||||||
|
| uniffi codegen internals shift on upgrade | pinned minor, symbol assertion at import, the "no asyncio loop" test, documented fallback to `to_asyncio` |
|
||||||
|
| rust-thread callback → trio wakeup mishandled (segfault / lost wakeup / un-cancellable task) | strong ref on the ctypes trampoline; `run_sync_soon` only; **bounded** shielded cancel-drain; run the `conc-anal` skill over the bridge |
|
||||||
|
| `iroh` wheel availability for 3.13/3.14 on linux+macos | verify in step 0; if missing, that alone may force the `aioquic` fallback |
|
||||||
|
| QUIC latency/jitter destabilizes the existing suite's timing assumptions | per-proto timeout multiplier, relay-less CI mode |
|
||||||
|
| `(str, str)` unwrapped form collides with UDS in `wrap_address()` | guarded case ordered first + explicit regression test (§3.2) |
|
||||||
|
| scope creep into iroh's docs/blobs/gossip crates | this backend is `Endpoint`+`Connection`+bi-streams only; anything else is a separate issue |
|
||||||
|
|
||||||
|
## 9. Follow-up issue seeds
|
||||||
|
|
||||||
|
- `tractor.discovery` delegating to iroh discovery (DNS/pkarr/mdns)
|
||||||
|
- per-`Context` QUIC sub-streams: today one `Channel` == one
|
||||||
|
stream; QUIC would let each `tractor.Context` own its own
|
||||||
|
stream with independent flow-control and cancellation — this
|
||||||
|
is the genuinely novel win #353 gestures at, and it's a
|
||||||
|
runtime-layer change, not a transport one
|
||||||
|
- unreliable QUIC datagrams for a lossy-ok broadcast transport
|
||||||
|
(pairs with plan 01's TIPC-multicast seed)
|
||||||
|
- node-id allowlist → a real `tractor` authz story
|
||||||
|
- `aioquic` sans-io backend reusing §3's adapters
|
||||||
|
|
@ -0,0 +1,487 @@
|
||||||
|
# Plan 03 — WireGuard (and other tunnels) as a *nested bindspace* via `pyroute2`
|
||||||
|
|
||||||
|
Tracks gh [#482] + the tunnelled-maddr item of [#443].
|
||||||
|
Prereq reading:
|
||||||
|
[`00_shared_backend_contract.md`](./00_shared_backend_contract.md).
|
||||||
|
|
||||||
|
**Thesis**: WireGuard is **not** a `MsgTransport`. It is an
|
||||||
|
interface-layer tunnel that is transparent to `socket(2)`, so
|
||||||
|
the correct abstraction is a *bindspace* — a scoped,
|
||||||
|
`@acm`-managed network context that an existing L4 transport
|
||||||
|
(`tcp`, and later `quic`/`tipc`-over-UDP-bearer) binds *inside*.
|
||||||
|
This plan implements `Address.namespace` (spec'd but unused
|
||||||
|
since day one) and the composed/tunnelled maddr grammar, with
|
||||||
|
`pyroute2` as the netlink codec and as much of the I/O moved
|
||||||
|
onto `trio` as the library's sans-io layer allows.
|
||||||
|
|
||||||
|
[#482]: https://github.com/goodboy/tractor/issues/482
|
||||||
|
[#443]: https://github.com/goodboy/tractor/issues/443
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What exists today (verified, per #482)
|
||||||
|
|
||||||
|
- `wrap_address()` accepts maddr `str`s (leading-`/` dispatch,
|
||||||
|
`_addr.py:262`) but `parse_maddr()` only knows
|
||||||
|
`/ip4|ip6/<h>/tcp/<p>` and `/unix/<p>`; a `.../wg/u<key>`
|
||||||
|
maddr raises `ValueError('Unsupported multiaddr protocol
|
||||||
|
combo')`.
|
||||||
|
- there is no `wg` proto in the multiaddr *spec* yet, but
|
||||||
|
multiformats/py-multiaddr#108 (key form `u<base64url>`) is
|
||||||
|
**merged** as of 2026-07-28 (`f86519da`) — and unreleased, the
|
||||||
|
latest `0.2.0` predating it. Spec registration is still tracked
|
||||||
|
by multiformats/py-multiaddr#107 and gh #483.
|
||||||
|
- so **today's deployable story is declarative**: run `wg-quick`
|
||||||
|
out-of-band, parse the maddr, strip to the overlay
|
||||||
|
`(host, port)`, verify the pubkey against the live tunnel,
|
||||||
|
hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`.
|
||||||
|
#482 already contains working example code for exactly this.
|
||||||
|
- `Address.namespace` exists in the Protocol
|
||||||
|
(`_addr.py:94-101`, "the if-available OS-specific network
|
||||||
|
namespace key") and **no backend implements it**. This plan is
|
||||||
|
its first consumer.
|
||||||
|
|
||||||
|
## 2. Three layers, three PRs
|
||||||
|
|
||||||
|
| layer | what | dep | ships |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **A. declarative** | commit #482's examples; `parse_maddr()` learns `/wg/u<key>` → overlay `Address` + verified pubkey | `multiaddr` (already), `wg(8)` CLI | first |
|
||||||
|
| **B. `pyroute2` read/verify** | replace the `subprocess.run(['sudo','wg','show'])` shelling with netlink queries | `pyroute2` extra | second |
|
||||||
|
| **C. `@acm` lifecycle** | create/configure/tear down wg ifaces + netns *from the runtime*, as nested bindspaces; implement `Address.namespace` | `pyroute2` + `CAP_NET_ADMIN` | third |
|
||||||
|
|
||||||
|
Each is independently valuable and independently reviewable.
|
||||||
|
**Do not attempt C first** — the interesting design (nested
|
||||||
|
bindspace `@acm`s) is only well-posed once A has pinned the
|
||||||
|
address grammar and B has proven the netlink path under trio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Layer A — declarative `wg` maddrs
|
||||||
|
|
||||||
|
### 3.1 the address shape
|
||||||
|
|
||||||
|
The decision: **a wg segment annotates an existing address, it
|
||||||
|
does not create a new address type.** Two candidate encodings;
|
||||||
|
**pick (a)**:
|
||||||
|
|
||||||
|
- **(a) `TunnelledAddress` wrapper** (recommended):
|
||||||
|
```python
|
||||||
|
class TunnelledAddress(
|
||||||
|
msgspec.Struct,
|
||||||
|
frozen=True,
|
||||||
|
):
|
||||||
|
overlay: Address # e.g. TCPAddress
|
||||||
|
tunnel: WGTunnelSpec # proto-specific, frozen
|
||||||
|
```
|
||||||
|
with `.proto_key` **delegating to `overlay.proto_key`** so every
|
||||||
|
existing table lookup (`_addr_to_transport`,
|
||||||
|
`enable_transports` guard at `_root.py:391`,
|
||||||
|
`transport_from_addr()`) keeps working untouched, and
|
||||||
|
`.unwrap()` delegating to `overlay.unwrap()` so **nothing new
|
||||||
|
crosses the wire**. `.namespace` and `.bindspace` come from
|
||||||
|
the tunnel spec. The wrapper is stripped (`→ .overlay`) at the
|
||||||
|
moment of bind/connect.
|
||||||
|
- ⚠️ `is_wrapped_addr()` (`_addr.py:194`) tests
|
||||||
|
`type(addr) in _address_types.values()` — a `bidict` of
|
||||||
|
proto_key→type. `TunnelledAddress` isn't in it and must not
|
||||||
|
be (it's not 1:1 with a proto). So either add an explicit
|
||||||
|
`isinstance(addr, TunnelledAddress)` clause there, or give
|
||||||
|
the wrapper a marker and test structurally. Do the former;
|
||||||
|
it's two lines and honest.
|
||||||
|
- the reflection in `Endpoint.start_listener()`
|
||||||
|
(`inspect.getmodule(self.addr)`) would resolve to the
|
||||||
|
*wrapper's* module, not the transport's. **So the wrapper
|
||||||
|
must be unwrapped before it reaches `Endpoint`** — i.e. by
|
||||||
|
the bindspace `@acm` (layer C) or by `parse_maddr()`
|
||||||
|
(layer A). State this loudly in the docstring; it's the #1
|
||||||
|
way to get this wrong.
|
||||||
|
- (b) add fields to each existing `Address` type. Rejected:
|
||||||
|
duplicates tunnel logic per-backend and pollutes `.unwrap()`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class WGTunnelSpec(
|
||||||
|
msgspec.Struct,
|
||||||
|
frozen=True,
|
||||||
|
):
|
||||||
|
peer_pubkey: str # std-base64 `wg(8)` form
|
||||||
|
iface: str = 'wg0'
|
||||||
|
netns: str|None = None
|
||||||
|
# layer-C-only fields, unset in layer A
|
||||||
|
maybe_endpoint: tuple[str, int]|None = None
|
||||||
|
maybe_allowed_ips: tuple[str, ...] = ()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 `parse_maddr()`/`mk_maddr()`
|
||||||
|
|
||||||
|
Grammar — **verified** against py-multiaddr#108, first on the
|
||||||
|
`baudco/py-multiaddr@wg_support` branch and re-verified after it
|
||||||
|
merged upstream (`multiformats/py-multiaddr@f86519da`); all three
|
||||||
|
forms below parse *and* round-trip. Note the codec also validates
|
||||||
|
that the key decodes to exactly 32 bytes, so a truncated key is a
|
||||||
|
`StringParseError`, not a silently-mangled parse:
|
||||||
|
|
||||||
|
```
|
||||||
|
/ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616
|
||||||
|
\_______ bearer __________/\__ key __/\______ overlay ______/
|
||||||
|
underlay, wg `ListenPort` the ONLY part we bind
|
||||||
|
```
|
||||||
|
|
||||||
|
The `/wg/` segment is **infix, not suffix** — the segments
|
||||||
|
*before* it are the wg **bearer** (the underlay `(ip, udp-port)`
|
||||||
|
that `wg(8)` itself listens on, per the codec docstring's own
|
||||||
|
`/ip4/1.2.3.4/udp/51820/wg/{key}` example), and the segments
|
||||||
|
*after* are the **overlay** endpoint that `tractor` binds.
|
||||||
|
|
||||||
|
⚠️ **CORRECTION** — an earlier revision of this plan (and the
|
||||||
|
examples in gh #482) used a *suffix* form
|
||||||
|
`/ip4/10.0.11.1/tcp/1616/wg/u<key>`. That parses, but it is
|
||||||
|
semantically inverted: it puts the overlay addr where the bearer
|
||||||
|
belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares
|
||||||
|
no overlay endpoint at all. `parse_wg_maddr()` in
|
||||||
|
`examples/multihost/wg_lan/` now rejects it with an actionable
|
||||||
|
error.
|
||||||
|
Observed protocol-name lists, for writing the `match`:
|
||||||
|
|
||||||
|
| maddr | `[p.name for p in m.protocols()]` |
|
||||||
|
| --- | --- |
|
||||||
|
| `/ip4/1.2.3.4/udp/51820/wg/u<k>` | `['ip4','udp','wg']` |
|
||||||
|
| `/ip4/../udp/../wg/u<k>/ip4/../tcp/..` | `['ip4','udp','wg','ip4','tcp']` |
|
||||||
|
|
||||||
|
- so the three parts have **three different owners**, and only the
|
||||||
|
third is an `Endpoint`:
|
||||||
|
|
||||||
|
| part | bound by | in the runtime? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| bearer | kernel, via `wg-quick`/`pyroute2` | no |
|
||||||
|
| `/wg/u<key>` | nothing — it's an identity | no, verified out-of-band |
|
||||||
|
| overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` |
|
||||||
|
|
||||||
|
This owner-split is the real axis of the design, *not* whether
|
||||||
|
the maddr stack is "composed" (it is).
|
||||||
|
- ⚠️ **CORRECTION**, an earlier draft of this section specced a
|
||||||
|
hand-rolled `_peel_tunnel_segs(proto_names) -> (bearer_names,
|
||||||
|
tunnel_specs, overlay_names)`. **Do not write it.**
|
||||||
|
`py-multiaddr` already ships the whole tunnel compose/peel API
|
||||||
|
and it was simply missed here — see its README "En/decapsulate"
|
||||||
|
and "Tunneling" sections, and gh #443's 2nd bullet which links
|
||||||
|
them. Verified against the pinned rev:
|
||||||
|
|
||||||
|
| need | API |
|
||||||
|
| --- | --- |
|
||||||
|
| isolate the bearer | `ma.decapsulate_code(P_WG)` |
|
||||||
|
| drop the overlay, keep bearer+key | `ma.decapsulate(overlay_ma)` |
|
||||||
|
| per-seg maddrs | `ma.split()` |
|
||||||
|
| rejoin a seg tail | `Multiaddr.join(*segs)` |
|
||||||
|
| read the key | `ma.value_for_protocol('wg')` |
|
||||||
|
| recompose | `bearer.encapsulate(key).encapsulate(overlay)` |
|
||||||
|
|
||||||
|
`.decapsulate_code()` handles the infix `/wg/` seg cleanly
|
||||||
|
*because* it cuts on proto-code and never tries to match an
|
||||||
|
addr value — the key seg has no addr of its own. This is the
|
||||||
|
same NIH trap gh #429 existed to close, one layer up.
|
||||||
|
|
||||||
|
- ⚠️ `value_for_protocol('ip4')` on a *full* tunnelled maddr
|
||||||
|
silently returns the **first** match, i.e. the bearer's host.
|
||||||
|
Always call it on a peeled sub-maddr, never the whole stack.
|
||||||
|
|
||||||
|
- `parse_maddr()` gains a case on
|
||||||
|
`[('ip4'|'ip6'), 'udp', 'wg', ('ip4'|'ip6'), <overlay-l4>]` →
|
||||||
|
peel w/ the API above, decode the multibase key to std-base64,
|
||||||
|
and return `TunnelledAddress(overlay=..., tunnel=WGTunnelSpec(
|
||||||
|
...))` w/ the bearer recorded in the spec.
|
||||||
|
- keep the existing 2-proto cases byte-identical; add the new
|
||||||
|
case *after* them.
|
||||||
|
- nesting (wg-in-wg) falls out of `.decapsulate_code()` cutting
|
||||||
|
at the *last* occurrence — peel repeatedly rather than
|
||||||
|
recursing through a bespoke splitter.
|
||||||
|
- `mk_maddr()` inverse for `TunnelledAddress` is just
|
||||||
|
`.encapsulate()` composition; don't rebuild `str`s by hand.
|
||||||
|
- **pending an upstream release**: py-multiaddr#108 is merged, so
|
||||||
|
`Multiaddr('/…/wg/u…')` parses — but off a `[tool.uv.sources]`
|
||||||
|
`rev` pin, since no release carries the codec. Gate the tests
|
||||||
|
on `_have_wg_maddr_proto()`, implemented as
|
||||||
|
`protocols.protocol_with_name('wg')` under
|
||||||
|
`except ProtocolNotFoundError`. Do **not** probe by parsing a
|
||||||
|
dummy like `Multiaddr('/wg/uAAAA')` — the codec enforces a
|
||||||
|
32-byte key, so that raises even when the proto *is* known. Do
|
||||||
|
**not** hand-roll a `wg` parser in `tractor` — the whole point
|
||||||
|
of #429 was dropping the NIH parser.
|
||||||
|
|
||||||
|
### 3.3 verification helper (pure, composable)
|
||||||
|
|
||||||
|
Port #482 §2's helpers into `tractor/discovery/_tunnel.py` as
|
||||||
|
*pure functions* + one impure probe, cleanly separated:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure
|
||||||
|
def wg8_pubkey(multibase_key: str) -> str: ... # pure
|
||||||
|
def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # impure probe
|
||||||
|
```
|
||||||
|
|
||||||
|
In layer A `verify_wg_peer()` may shell out (`wg show <if>
|
||||||
|
peers`), but it must be a *single* function so layer B swaps
|
||||||
|
only its body. Never call it implicitly from
|
||||||
|
`wrap_address()`/`parse_maddr()` — parsing must stay pure and
|
||||||
|
side-effect-free; verification is the *caller's* explicit step
|
||||||
|
(and later, the bindspace `@acm`'s).
|
||||||
|
|
||||||
|
### 3.4 deliverables
|
||||||
|
|
||||||
|
- `examples/` scripts distilled from #482 §§3-5 (this is the
|
||||||
|
unchecked "commit examples from ^" bullet in #443). They live
|
||||||
|
under `examples/multihost/` — `test_docs_examples.py` walks
|
||||||
|
`examples/` recursively and runs every collected file as a
|
||||||
|
subproc asserting `rc == 0` (it doesn't even filter by
|
||||||
|
extension, so a stray `README.md` would be `python`-run too),
|
||||||
|
and `'multihost' not in p[0]` is already in its exclusion
|
||||||
|
list. Anything needing a real second host or a live tunnel
|
||||||
|
belongs there.
|
||||||
|
- a `docs/` page: tunnel setup, the maddr form, the two-host
|
||||||
|
run. Keep prose in the docs; keep the examples runnable and
|
||||||
|
minimal.
|
||||||
|
- tests: maddr round-trip, `TunnelledAddress` delegation
|
||||||
|
(`proto_key`/`unwrap` identical to overlay), `wrap_address()`
|
||||||
|
regression (a tunnelled maddr `str` → `TunnelledAddress`; a
|
||||||
|
plain one → unchanged), and **a real end-to-end over a
|
||||||
|
locally-created wg pair** gated on `CAP_NET_ADMIN` (see §5.3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Layer B — `pyroute2` under `trio`
|
||||||
|
|
||||||
|
### 4.1 the library situation (verify at implementation time)
|
||||||
|
|
||||||
|
`pyroute2` ≥0.9 rewrote its core onto **asyncio**
|
||||||
|
(`AsyncIPRoute`; the sync `IPRoute` wraps it with its own loop).
|
||||||
|
It also ships a `WireGuard` netlink (generic-netlink) module
|
||||||
|
supporting `.set(iface, private_key=..., peer={...})` and
|
||||||
|
`.info(iface)`, plus `pyroute2.netns` / `NetNS` for namespaces,
|
||||||
|
and `IPRoute.link('add', kind='wireguard', ifname=...)`.
|
||||||
|
|
||||||
|
Three integration options, in increasing trio-nativeness:
|
||||||
|
|
||||||
|
- **(1) `trio.to_thread.run_sync()` around the sync API.**
|
||||||
|
Netlink ops here are one-shot, sub-millisecond, and happen at
|
||||||
|
bind/teardown time only — *not* in the msg hot path. This is
|
||||||
|
the **correct default**: it's ~10 lines, uses a battle-tested
|
||||||
|
API, and costs nothing where it's used.
|
||||||
|
- **(2) sans-io: `trio.socket` + pyroute2's message codecs.**
|
||||||
|
`pyroute2`'s message classes
|
||||||
|
(`pyroute2.netlink.rtnl.*`, `pyroute2.netlink.generic.wireguard.wgmsg`)
|
||||||
|
encode/decode independently of its I/O core. So a
|
||||||
|
`tractor/ipc/_netlink.py` with a small trio `NetlinkSocket`
|
||||||
|
(`trio.socket.socket(AF_NETLINK, SOCK_RAW|SOCK_DGRAM, proto)`,
|
||||||
|
`sendto`/`recv`, seq/pid matching, `NLMSG_DONE`/`NLMSG_ERROR`
|
||||||
|
handling) + pyroute2 codecs is very achievable and is the
|
||||||
|
honest reading of "as much trio wrapping as possible where any
|
||||||
|
other async support can be replaced".
|
||||||
|
**Do this for the paths we actually need** (link add/del,
|
||||||
|
addr add, wg get/set, netns bind) and *only* those — a
|
||||||
|
general netlink client is out of scope.
|
||||||
|
- (3) reimplement the codecs. Never.
|
||||||
|
|
||||||
|
**Recommended split**: ship (1) first so layer B is a small,
|
||||||
|
reviewable, behaviour-preserving swap of `verify_wg_peer()`'s
|
||||||
|
body; then land (2) as a follow-up commit for the read path
|
||||||
|
(`wg get`, `link get`) where the sans-io surface is smallest,
|
||||||
|
and keep (1) for the privileged mutating ops. Measure before
|
||||||
|
converting anything else — there is no perf argument here, only
|
||||||
|
a "no foreign event loop in a trio actor" argument, which (1)
|
||||||
|
already satisfies (a thread is not an event loop).
|
||||||
|
|
||||||
|
Explicitly **do not** pull in `trio-asyncio` for pyroute2: it
|
||||||
|
would be the one place in the runtime where an asyncio loop
|
||||||
|
exists for no reason.
|
||||||
|
|
||||||
|
### 4.2 API shape
|
||||||
|
|
||||||
|
Pure-ish, functional, `@acm` for anything with teardown:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def read_wg_peers(
|
||||||
|
iface: str = 'wg0',
|
||||||
|
netns: str|None = None,
|
||||||
|
) -> tuple[str, ...]: ... # base64 pubkeys
|
||||||
|
|
||||||
|
async def read_wg_pubkey(iface: str = 'wg0', ...) -> str: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
and `verify_wg_peer()` becomes a thin composition over the two.
|
||||||
|
Note the pure-getter rule: no `read_wg_peers(..., create=True)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Layer C — nested bindspace `@acm`s + `Address.namespace`
|
||||||
|
|
||||||
|
This is the part #443 and `multiaddr_declare_eps.md` actually
|
||||||
|
ask for: *"for any tunneled maddr-`str`-entry we deliver a
|
||||||
|
data-structure which can easily be passed to nested `@acm`s
|
||||||
|
which consecutively setup nested net bindspaces for binding the
|
||||||
|
endpoint addrs"*.
|
||||||
|
|
||||||
|
### 5.1 the composition
|
||||||
|
|
||||||
|
```python
|
||||||
|
@acm
|
||||||
|
async def open_bindspace(
|
||||||
|
addr: TunnelledAddress,
|
||||||
|
) -> AsyncGenerator[Address, None]:
|
||||||
|
'''
|
||||||
|
Enter the net-bindspace implied by `addr`'s tunnel stack,
|
||||||
|
yielding the *overlay* `Address` ready to bind/connect.
|
||||||
|
|
||||||
|
Nests: one `@acm` per tunnel segment, outermost-first, so
|
||||||
|
a 2-deep stack is just two nested `async with`s and the
|
||||||
|
teardown order is guaranteed by `trio`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
```
|
||||||
|
|
||||||
|
with per-tunnel-kind implementations:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@acm
|
||||||
|
async def open_netns(name: str) -> AsyncGenerator[None, None]: ...
|
||||||
|
@acm
|
||||||
|
async def open_wg_iface(spec: WGTunnelSpec) -> AsyncGenerator[WGTunnelSpec, None]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
and a driver that folds a list of specs into nested contexts
|
||||||
|
(`contextlib.AsyncExitStack` for the N-deep case). The
|
||||||
|
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
||||||
|
it already returns `dict[name, list[Address]]` and the
|
||||||
|
`multiaddr_declare_eps.md` sketch anticipates the recursive
|
||||||
|
`dict[str, list[Address]]|dict[...]` return for tunnelled
|
||||||
|
entries. Extend it to carry the tunnel stack, not to *enter* it.
|
||||||
|
|
||||||
|
### 5.2 `Address.namespace`, at last
|
||||||
|
|
||||||
|
- `TunnelledAddress.namespace` → `(kind, id)` e.g.
|
||||||
|
`('netns', 'tractor-wg0')`.
|
||||||
|
- **and** the existing backends should implement it as `None`
|
||||||
|
explicitly (they currently just don't define it), so the
|
||||||
|
Protocol stops lying.
|
||||||
|
- consumers to audit: nothing reads `.namespace` today — so
|
||||||
|
adding it is safe, but the *point* is that
|
||||||
|
`Endpoint`/`Server.pformat()` should start showing it (there's
|
||||||
|
already a `# !TODO, always be ns aware!` +
|
||||||
|
`f'|_netns: {netns}\n'` placeholder sitting in
|
||||||
|
`Endpoint.pformat()`, `_server.py:645`). Fill that in; it's
|
||||||
|
the cheapest possible proof the layer is wired.
|
||||||
|
|
||||||
|
### 5.3 the netns/process reality — read this before designing
|
||||||
|
|
||||||
|
**The headline consequence, stated up front**: netns is a
|
||||||
|
**runtime-level config API, not an actor-app-code API.** It is
|
||||||
|
declared as part of how an actor process is *brought up* — a
|
||||||
|
spawn-time/boot-time input alongside `enable_transports` and
|
||||||
|
`tpt_bind_addrs` — and it is **not** dynamically re-enterable by
|
||||||
|
app code once the actor is live. There is deliberately no
|
||||||
|
`await actor.enter_netns(...)`. Two hard reasons, both below:
|
||||||
|
`setns(2)` doesn't retroactively move existing sockets, and it's
|
||||||
|
per-thread rather than per-process. Anything that *looks* like a
|
||||||
|
mid-life API here would be a footgun that silently leaves the IPC
|
||||||
|
server bound in the old namespace.
|
||||||
|
|
||||||
|
- `setns(2)` with `CLONE_NEWNET` affects **the calling thread
|
||||||
|
only**, and sockets already created keep their original netns.
|
||||||
|
A trio actor is effectively single-threaded for our purposes,
|
||||||
|
so "enter the netns, *then* bind" works — but any
|
||||||
|
`to_thread` worker (§4.1 option 1!) is in the **original**
|
||||||
|
netns unless it also `setns`. Concretely: a wg query issued
|
||||||
|
via `trio.to_thread` will hit the wrong namespace. Either
|
||||||
|
pass `netns=` down to `pyroute2` (which does the
|
||||||
|
fork/setns dance itself) or pin a dedicated worker. **This is
|
||||||
|
the single subtlest bug in this plan — write the test first.**
|
||||||
|
- entering a netns is *process-global-ish and irreversible-ish*
|
||||||
|
in practice. Therefore: **netns membership belongs to the
|
||||||
|
actor process, decided before the runtime binds**, not to a
|
||||||
|
mid-life `@acm`. Design:
|
||||||
|
- the root/parent decides the netns for a subactor and passes
|
||||||
|
it in the spawn spec (there's already
|
||||||
|
`enable_transports`/`accept_addrs` plumbing at
|
||||||
|
`_runtime.py:1595-1615` — the netns rides alongside).
|
||||||
|
- the child, in `_runtime.async_main()` **before**
|
||||||
|
`IPCServer.listen_on()`, enters it.
|
||||||
|
- the mid-life `@acm` form is then only for the *root* /
|
||||||
|
single-actor case, and for iface creation (which is
|
||||||
|
genuinely scoped).
|
||||||
|
- document the constraint rather than hiding it; a
|
||||||
|
`RuntimeError` if `open_netns()` is entered after any
|
||||||
|
listener exists.
|
||||||
|
- privileges: iface/netns creation needs `CAP_NET_ADMIN`.
|
||||||
|
Never `sudo` from inside the runtime. Two supported modes:
|
||||||
|
(i) pre-provisioned out-of-band (layers A/B — the default,
|
||||||
|
and what #482 documents), (ii) runtime-managed when the
|
||||||
|
process already holds the cap. Detect with a cheap
|
||||||
|
`os.geteuid()==0 or CAP_NET_ADMIN in /proc/self/status`
|
||||||
|
probe and *fail loudly with an actionable message* otherwise.
|
||||||
|
- teardown must be idempotent and tolerant: an iface/netns
|
||||||
|
already gone must not strand the rest of the teardown — the
|
||||||
|
exact lesson `_uds.close_listener()`'s `FileNotFoundError`
|
||||||
|
tolerance and `_serve_ipc_eps()`'s per-ep `try/except`
|
||||||
|
encode. Mirror both.
|
||||||
|
|
||||||
|
### 5.4 tests for layer C
|
||||||
|
|
||||||
|
- unit: fold-N-tunnel-specs-into-nested-`@acm`s, with fakes; assert
|
||||||
|
enter/exit ordering (outermost-last-out) via a trace list.
|
||||||
|
- integration, gated on `CAP_NET_ADMIN` (skip otherwise, and in
|
||||||
|
CI run it in a `--cap-add NET_ADMIN` container job): create two
|
||||||
|
netns + a wg pair entirely in-process, boot a `tractor` root in
|
||||||
|
one and a subactor in the other, `find_actor()` across the
|
||||||
|
tunnel. This is a *fantastic* test to have and is fully
|
||||||
|
self-contained — no second host, no `sudo` in the test body.
|
||||||
|
- the `to_thread`-netns-mismatch regression from §5.3, written
|
||||||
|
**first** (red), then the fix (green), per project convention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. "Other shuttle-able tpts"
|
||||||
|
|
||||||
|
The generalization the #482 follow-up gestures at: once
|
||||||
|
`TunnelledAddress` + `open_bindspace()` exist, the same
|
||||||
|
machinery covers any iface-layer tunnel `pyroute2` can drive —
|
||||||
|
`ipip`/`gre`/`sit`/`vxlan`/`geneve`/`bridge`/`veth`. Keep
|
||||||
|
`WGTunnelSpec` as *one* frozen struct among a
|
||||||
|
`TunnelSpec = WGTunnelSpec|VxlanTunnelSpec|...` union with a
|
||||||
|
`kind: ClassVar[str]`, and dispatch `open_*` by `match` on it.
|
||||||
|
Design for it now (union + `match`), implement only `wg` +
|
||||||
|
`netns`. `veth`-pairs-in-netns is the natural second one because
|
||||||
|
it makes the §5.4 integration test possible without wg at all —
|
||||||
|
consider doing it *first* for exactly that reason.
|
||||||
|
|
||||||
|
## 7. Non-goals
|
||||||
|
|
||||||
|
- no wg userspace implementation, no key exchange, no
|
||||||
|
`wg-quick` reimplementation (config-file parsing is
|
||||||
|
out of scope; take structured input).
|
||||||
|
- no persistence of private keys beyond what layer C's iface
|
||||||
|
creation needs (and that stays in `get_rt_dir()`, 0600).
|
||||||
|
- macOS/Windows: layers B/C are Linux-only. Layer A (declarative)
|
||||||
|
works anywhere `wg` does. Gate accordingly and say so in the
|
||||||
|
docs — do not silently no-op.
|
||||||
|
|
||||||
|
## 8. Risks
|
||||||
|
|
||||||
|
| risk | mitigation |
|
||||||
|
| --- | --- |
|
||||||
|
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first |
|
||||||
|
| py-multiaddr#108 merged but unreleased | `[tool.uv.sources]` `rev` pin + `_have_wg_maddr_proto()` gate; layer A's overlay-addr path works regardless |
|
||||||
|
| `TunnelledAddress` leaks into `Endpoint` and breaks `inspect.getmodule()` | unwrap at parse/bindspace boundary; assert `not isinstance(ep.addr, TunnelledAddress)` in `Endpoint.__post_init__` |
|
||||||
|
| privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default |
|
||||||
|
| pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) |
|
||||||
|
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
|
||||||
|
|
||||||
|
## 9. Follow-up issue seeds
|
||||||
|
|
||||||
|
- `veth`-in-netns bindspace (unblocks capless-ish integration
|
||||||
|
testing, and is a great local multi-"host" test rig)
|
||||||
|
- composed/tunnelled maddr grammar shared with plan 02's
|
||||||
|
`/…/quic-v1/…` stacks (gh #443)
|
||||||
|
- `wg` proto into the multiaddr **spec** (gh #483), then flip
|
||||||
|
`MsgTransport.maddr` to always return `Multiaddr` (the third
|
||||||
|
#443 bullet)
|
||||||
|
- runtime-managed wg key rotation / peer add-remove as a
|
||||||
|
`tractor` service actor — the natural "actor that owns the
|
||||||
|
network" demo
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# next-gen `tractor.ipc` transport backend plans
|
||||||
|
|
||||||
|
Implementation specs for three prospective `.ipc` transport
|
||||||
|
backends, written so each can be worked independently (by a
|
||||||
|
different model/provider) without design or lib-selection drift.
|
||||||
|
|
||||||
|
**Read [`00_shared_backend_contract.md`](./00_shared_backend_contract.md)
|
||||||
|
first** — it is the normative description of what a `tractor`
|
||||||
|
transport backend *is* as of `main@83b34884` (the backend
|
||||||
|
duck-type, the 10-item registration checklist, the test-harness
|
||||||
|
plumbing, the code-style rules). The three plans assume it and
|
||||||
|
document only their own deltas.
|
||||||
|
|
||||||
|
| plan | issue | dep | size | lands |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| [01 — TIPC](./01_tipc_backend.md) | [#378] | **none** (stdlib) | small | first |
|
||||||
|
| [02 — QUIC/`iroh`](./02_quic_iroh_backend.md) | [#353] | `iroh` (uniffi FFI) | large | needs a prep PR |
|
||||||
|
| [03 — `wg` bindspace](./03_wg_tunnel_bindspace.md) | [#482], [#443] | `pyroute2` | medium, 3 layers | layer A now |
|
||||||
|
|
||||||
|
Headline conclusions:
|
||||||
|
|
||||||
|
- **TIPC is the cheap win.** Verified: `trio.SocketStream` and
|
||||||
|
`trio.SocketListener` are address-family agnostic (only
|
||||||
|
`SOCK_STREAM` + a trio socket), and CPython ships `AF_TIPC` +
|
||||||
|
23 `TIPC_*` constants. So the backend is ~one module of
|
||||||
|
contract boilerplate, zero new deps, and it buys
|
||||||
|
*kernel-native* service discovery: `bind()` publishes,
|
||||||
|
`connect()`-by-name resolves — no registrar in the loop.
|
||||||
|
(`modprobe tipc` is required; hard-gate everything.)
|
||||||
|
- **QUIC's cost is entirely in two adapters**, not in QUIC. The
|
||||||
|
`iroh` python bindings are `uniffi`-generated asyncio, but the
|
||||||
|
asyncio dependency is confined to *one* future-poll callback —
|
||||||
|
a ~40-line `trio` bridge (`TrioToken.run_sync_soon`) replaces
|
||||||
|
it. The second cost is that an iroh listener isn't a socket,
|
||||||
|
which needs a small, independently-reviewable prep PR to
|
||||||
|
`_server.py`/`_types.py`.
|
||||||
|
- **WireGuard is not a transport.** It's an iface-layer tunnel,
|
||||||
|
so it belongs as a *nested bindspace* (`TunnelledAddress` +
|
||||||
|
`open_bindspace()` `@acm`s) wrapping whatever L4 tpt is in
|
||||||
|
use — which is also what finally implements the long-spec'd
|
||||||
|
`Address.namespace`, and what generalizes to
|
||||||
|
`veth`/`vxlan`/`gre`.
|
||||||
|
|
||||||
|
Ordering rationale: plan 01 first as the cheap proof the
|
||||||
|
table-registration story generalizes to a genuinely new proto;
|
||||||
|
plan 03 layer A is already deployable-today doc/example work;
|
||||||
|
plan 02 last (and gated on its prep PR). Plans 01 and 02 both
|
||||||
|
want the same `Address.rebind_from_sockname` gate — whichever
|
||||||
|
lands first ships it.
|
||||||
|
|
||||||
|
[#378]: https://github.com/goodboy/tractor/issues/378
|
||||||
|
[#353]: https://github.com/goodboy/tractor/issues/353
|
||||||
|
[#482]: https://github.com/goodboy/tractor/issues/482
|
||||||
|
[#443]: https://github.com/goodboy/tractor/issues/443
|
||||||
|
|
@ -37,7 +37,6 @@ Spawning actors
|
||||||
|
|
||||||
.. autoclass:: ActorNursery
|
.. autoclass:: ActorNursery
|
||||||
:members: start_actor,
|
:members: start_actor,
|
||||||
run_in_actor,
|
|
||||||
cancel,
|
cancel,
|
||||||
cancel_called,
|
cancel_called,
|
||||||
cancelled_caught
|
cancelled_caught
|
||||||
|
|
@ -46,11 +45,25 @@ Spawning actors
|
||||||
|
|
||||||
:meth:`ActorNursery.start_actor` (daemon actor + portal) is the
|
:meth:`ActorNursery.start_actor` (daemon actor + portal) is the
|
||||||
blessed spawning primitive; pair it with
|
blessed spawning primitive; pair it with
|
||||||
``Portal.open_context()`` for SC-linked remote tasks.
|
:meth:`Portal.open_context` for SC-linked remote tasks.
|
||||||
:meth:`ActorNursery.run_in_actor` is a *convenience* one-shot —
|
|
||||||
spawn, run a single task, auto-cancel after the result — slated
|
One-shot task actors
|
||||||
to be rebuilt as a high-level wrapper, so don't design around
|
--------------------
|
||||||
it as the core model.
|
|
||||||
|
.. autofunction:: tractor.to_actor.run
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Without ``portal=``, :func:`tractor.to_actor.run` (parlance of
|
||||||
|
``trio.to_thread.run_sync()`` and friends) is the convenience
|
||||||
|
one-shot: spawn, run one task, block on its result and reap. It
|
||||||
|
combines :meth:`ActorNursery.start_actor`, a linked
|
||||||
|
:meth:`Portal.open_context` call and per-child reaping. With
|
||||||
|
``portal=`` it owns only the linked task and leaves the existing
|
||||||
|
actor's lifetime to the portal owner; that actor must expose both
|
||||||
|
the target module and ``tractor.to_actor.MODULE``. It supersedes
|
||||||
|
the removed (legacy, non-blocking)
|
||||||
|
``ActorNursery.run_in_actor()``.
|
||||||
|
|
||||||
.. deprecated:: 0.1.0a6
|
.. deprecated:: 0.1.0a6
|
||||||
|
|
||||||
|
|
@ -71,14 +84,12 @@ flowing back `exactly like trio`_.
|
||||||
:members: run,
|
:members: run,
|
||||||
run_from_ns,
|
run_from_ns,
|
||||||
open_stream_from,
|
open_stream_from,
|
||||||
wait_for_result,
|
|
||||||
cancel_actor,
|
cancel_actor,
|
||||||
chan
|
chan
|
||||||
|
|
||||||
.. deprecated:: 0.1.0a6
|
.. deprecated:: 0.1.0a6
|
||||||
|
|
||||||
``Portal.result()`` warns; use :meth:`Portal.wait_for_result`.
|
The str-form ``Portal.run('mod.path', 'fn_name')`` warns;
|
||||||
The str-form ``Portal.run('mod.path', 'fn_name')`` also warns;
|
|
||||||
pass a function *object* whose module is listed in the target's
|
pass a function *object* whose module is listed in the target's
|
||||||
``enable_modules``. ``Portal.channel`` is the legacy spelling
|
``enable_modules``. ``Portal.channel`` is the legacy spelling
|
||||||
of :attr:`Portal.chan`.
|
of :attr:`Portal.chan`.
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ This is the curated reference for ``tractor``'s public surface: the
|
||||||
names you can import and lean on without reading runtime internals.
|
names you can import and lean on without reading runtime internals.
|
||||||
Everything below is re-exported at the top level (``import
|
Everything below is re-exported at the top level (``import
|
||||||
tractor``) unless a page says otherwise; subsystems like
|
tractor``) unless a page says otherwise; subsystems like
|
||||||
``tractor.msg``, ``tractor.trionics``, ``tractor.to_asyncio``,
|
``tractor.msg``, ``tractor.trionics``, ``tractor.to_actor``,
|
||||||
``tractor.devx`` and ``tractor.log`` are importable as submodules.
|
``tractor.to_asyncio``, ``tractor.devx`` and ``tractor.log`` are
|
||||||
|
importable as submodules.
|
||||||
|
|
||||||
``tractor`` is "just trio_" extended across processes: every API
|
``tractor`` is "just trio_" extended across processes: every API
|
||||||
here is designed to keep the structured concurrency (SC) rules you
|
here is designed to keep the structured concurrency (SC) rules you
|
||||||
|
|
@ -23,6 +24,7 @@ Most-used names at a glance:
|
||||||
|
|
||||||
open_root_actor
|
open_root_actor
|
||||||
open_nursery
|
open_nursery
|
||||||
|
to_actor.run
|
||||||
run_daemon
|
run_daemon
|
||||||
ActorNursery
|
ActorNursery
|
||||||
Portal
|
Portal
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,12 @@ Starting asyncio tasks from trio
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
:func:`open_channel_from` mirrors the
|
:func:`open_channel_from` mirrors the
|
||||||
``Portal.open_context()`` handshake: the asyncio side calls
|
:meth:`tractor.Portal.open_context` handshake: the asyncio side calls
|
||||||
``chan.started_nowait(value)`` and that value pops out as
|
``chan.started_nowait(value)`` and that value pops out as
|
||||||
``first`` on the trio side. :func:`run_task` is the one-shot
|
``first`` on the trio side. :func:`run_task` is the one-shot
|
||||||
form — run a single asyncio-compatible coroutine fn and return
|
form — run a single asyncio-compatible coroutine fn and return
|
||||||
its result to trio.
|
its result to trio; :func:`tractor.to_actor.run` is its
|
||||||
|
cross-process sibling.
|
||||||
|
|
||||||
The inter-loop channel
|
The inter-loop channel
|
||||||
----------------------
|
----------------------
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,10 @@ UDS: same-host, creds included
|
||||||
|
|
||||||
Pass ``enable_transports=['uds']`` and actors instead talk over
|
Pass ``enable_transports=['uds']`` and actors instead talk over
|
||||||
unix-domain sockets, with socket files placed in the per-user
|
unix-domain sockets, with socket files placed in the per-user
|
||||||
runtime dir (``$XDG_RUNTIME_DIR/tractor/`` on linux, the
|
runtime dir: ``$XDG_RUNTIME_DIR/tractor/`` on linux, a short
|
||||||
``platformdirs`` equivalent elsewhere). Two perks over tcp on a
|
owner-only ``/tmp/tractor-<uid>`` dir on Darwin, and the
|
||||||
single host:
|
``platformdirs`` equivalent elsewhere. Two perks over tcp on a single
|
||||||
|
host:
|
||||||
|
|
||||||
- no ports to fight over; addrs are just file paths,
|
- no ports to fight over; addrs are just file paths,
|
||||||
- the kernel snitches on your peer for free: the listening side
|
- the kernel snitches on your peer for free: the listening side
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,8 @@ Just flip the flag on :meth:`tractor.ActorNursery.start_actor`:
|
||||||
infect_asyncio=True,
|
infect_asyncio=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
The one-shot convenience ``ActorNursery.run_in_actor()`` accepts
|
The one-shot convenience ``tractor.to_actor.run()`` accepts the
|
||||||
the same flag. The ``to_asyncio`` APIs may **only** be called from
|
same flag. The ``to_asyncio`` APIs may **only** be called from
|
||||||
tasks inside an infected actor; calling them anywhere else raises
|
tasks inside an infected actor; calling them anywhere else raises
|
||||||
a loud ``RuntimeError``. You can introspect at runtime with
|
a loud ``RuntimeError``. You can introspect at runtime with
|
||||||
``tractor.current_actor().is_infected_aio()``.
|
``tractor.current_actor().is_infected_aio()``.
|
||||||
|
|
@ -229,7 +229,7 @@ dialog, skip the channel ceremony and use
|
||||||
|
|
||||||
It schedules the fn as an ``asyncio.Task``, waits for completion
|
It schedules the fn as an ``asyncio.Task``, waits for completion
|
||||||
and hands the return value back to ``trio``; think of it as the
|
and hands the return value back to ``trio``; think of it as the
|
||||||
cross-loop sibling of ``ActorNursery.run_in_actor()``. Errors and
|
cross-loop sibling of ``tractor.to_actor.run()``. Errors and
|
||||||
cancellation are translated exactly as for channels.
|
cancellation are translated exactly as for channels.
|
||||||
|
|
||||||
Cross-loop errors and cancellation
|
Cross-loop errors and cancellation
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,13 @@ What's going on here?
|
||||||
- three healthy actors are spawned as daemons via
|
- three healthy actors are spawned as daemons via
|
||||||
:meth:`tractor.ActorNursery.start_actor`; left alone they'd
|
:meth:`tractor.ActorNursery.start_actor`; left alone they'd
|
||||||
happily idle forever,
|
happily idle forever,
|
||||||
- a fourth actor runs ``assert_err()`` via ``.run_in_actor()`` and
|
- a fourth actor runs ``assert_err()`` via a blocking
|
||||||
promptly trips its ``assert 0``,
|
``tractor.to_actor.run()`` one-shot and promptly trips its
|
||||||
|
``assert 0``,
|
||||||
- the resulting ``AssertionError`` ships back over IPC as a
|
- the resulting ``AssertionError`` ships back over IPC as a
|
||||||
serialized error msg and re-raises *boxed* inside the nursery
|
serialized error msg and re-raises *boxed* right at the call
|
||||||
block as a :class:`tractor.RemoteActorError`,
|
inside the nursery block as a
|
||||||
|
:class:`tractor.RemoteActorError`,
|
||||||
- the nursery reacts like any ``trio`` nursery would: it cancels
|
- the nursery reacts like any ``trio`` nursery would: it cancels
|
||||||
the three healthy siblings (graceful runtime-cancel requests,
|
the three healthy siblings (graceful runtime-cancel requests,
|
||||||
acks awaited), reaps all four processes, then re-raises,
|
acks awaited), reaps all four processes, then re-raises,
|
||||||
|
|
@ -228,22 +230,23 @@ Graceful first, hard as a last resort
|
||||||
|
|
||||||
The hard-kill path is *skipped* whenever an actor in the tree
|
The hard-kill path is *skipped* whenever an actor in the tree
|
||||||
holds the debug-REPL lock (``debug_mode=True`` flavors):
|
holds the debug-REPL lock (``debug_mode=True`` flavors):
|
||||||
SIGTERM raining down on a tree mid-``pdb`` session would
|
Process signals raining down on a tree mid-``pdb`` session would
|
||||||
clobber your prompt. See :doc:`/guide/debugging`.
|
clobber your prompt. See :doc:`/guide/debugging`.
|
||||||
|
|
||||||
Every process teardown in ``tractor`` walks the same escalation
|
Owned-child teardown in ``tractor`` begins with the same graceful
|
||||||
ladder, top rung first,
|
steps, then selects the escalation path used by its supervisor,
|
||||||
|
|
||||||
1. **graceful cancel request**: a runtime-cancel msg over IPC; the
|
1. **graceful cancel request**: a runtime-cancel msg over IPC; the
|
||||||
target actor cancels its tasks, closes its channels and exits
|
target actor cancels its tasks, closes its channels and exits
|
||||||
its :func:`trio.run` cleanly,
|
its :func:`trio.run` cleanly,
|
||||||
2. **soft wait**: the parent waits (bounded) for the child process
|
2. **soft wait**: the parent waits (bounded) for the child process
|
||||||
to exit on its own,
|
to exit on its own,
|
||||||
3. **SIGTERM**: no ack within the bounded wait (internally an
|
3. **actor-nursery hard reap**: no cancel ack within the bounded wait
|
||||||
``ActorTooSlowError``) escalates to ``proc.terminate()``,
|
(internally an ``ActorTooSlowError``) escalates directly to
|
||||||
4. **SIGKILL ultimatum**: still alive after the hard-kill timeout
|
``proc.kill()`` before the child monitor joins the process,
|
||||||
(~1.6s)? The runtime logs that the "T-800" has been deployed to
|
4. **legacy soft-kill path**: older teardown callers may first issue
|
||||||
collect the zombie and issues ``proc.kill()``. No survivors.
|
``proc.terminate()`` and then deploy the "T-800" ``proc.kill()``
|
||||||
|
ultimatum if the process survives that additional bounded wait.
|
||||||
|
|
||||||
The result is the **no-zombies guarantee**: ``tractor`` tries to
|
The result is the **no-zombies guarantee**: ``tractor`` tries to
|
||||||
protect you from zombies, no matter what. Quoting the project
|
protect you from zombies, no matter what. Quoting the project
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,10 @@ one kwarg away,
|
||||||
.. code:: python
|
.. code:: python
|
||||||
|
|
||||||
async with tractor.open_actor_cluster(
|
async with tractor.open_actor_cluster(
|
||||||
modules=['mylib.workers'],
|
modules=[
|
||||||
|
'mylib.workers',
|
||||||
|
tractor.to_actor.MODULE,
|
||||||
|
],
|
||||||
count=4,
|
count=4,
|
||||||
names=['scout', 'miner', 'smelter', 'smith'],
|
names=['scout', 'miner', 'smelter', 'smith'],
|
||||||
debug_mode=True, # whole-fleet crash-to-REPL
|
debug_mode=True, # whole-fleet crash-to-REPL
|
||||||
|
|
@ -70,9 +73,12 @@ one kwarg away,
|
||||||
...
|
...
|
||||||
|
|
||||||
From here the composition patterns are the usual ``tractor`` fare:
|
From here the composition patterns are the usual ``tractor`` fare:
|
||||||
``portal.run()`` for one-shot calls (as in the demo), or — for a
|
``portal.run()`` for bare one-shot RPCs (as in the demo),
|
||||||
persistent bidirectional dialog per worker — concurrently enter N
|
``tractor.to_actor.run(..., portal=portal)`` for cancellation-linked
|
||||||
``portal.open_context()`` blocks with
|
one-shot tasks in an existing worker (include
|
||||||
|
``tractor.to_actor.MODULE`` in ``modules``; the cluster still owns
|
||||||
|
the worker's lifetime), or — for a persistent bidirectional dialog
|
||||||
|
per worker — concurrently enter N ``portal.open_context()`` blocks with
|
||||||
``tractor.trionics.gather_contexts()``; see :doc:`/guide/context`
|
``tractor.trionics.gather_contexts()``; see :doc:`/guide/context`
|
||||||
for that whole layer.
|
for that whole layer.
|
||||||
|
|
||||||
|
|
@ -87,8 +93,8 @@ Clusters vs. nurseries
|
||||||
|
|
||||||
``open_actor_cluster()`` is sugar, not a new primitive: under the
|
``open_actor_cluster()`` is sugar, not a new primitive: under the
|
||||||
hood it's just :func:`tractor.open_nursery` plus N concurrent
|
hood it's just :func:`tractor.open_nursery` plus N concurrent
|
||||||
``start_actor()`` calls plus a ``.cancel()`` on the way out. Reach
|
:meth:`~tractor.ActorNursery.start_actor` calls plus a ``.cancel()``
|
||||||
for it when,
|
on the way out. Reach for it when,
|
||||||
|
|
||||||
- you want a *flat*, homogeneous fleet (classic worker-pool or
|
- you want a *flat*, homogeneous fleet (classic worker-pool or
|
||||||
map-style fan-out shapes),
|
map-style fan-out shapes),
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,12 @@ a single `structured concurrency`_ (SC) scope over IPC.
|
||||||
:alt: sequence diagram of the context handshake msg flow
|
:alt: sequence diagram of the context handshake msg flow
|
||||||
|
|
||||||
Pretty much everything else is (or is slated to be) built on this
|
Pretty much everything else is (or is slated to be) built on this
|
||||||
one primitive: ``ActorNursery.run_in_actor()`` is a convenience
|
one primitive: ``tractor.to_actor.run()`` uses it for a linked
|
||||||
for "spawn, open a context, await the result, tear down"; plain
|
one-shot task, spawning and reaping an actor only when no ``portal=``
|
||||||
``Portal.run()`` RPC is planned to be re-implemented on top of it;
|
is supplied; plain ``Portal.run()`` RPC is planned to be
|
||||||
the multi-process debugger's tree-wide REPL lock rides one. Grok
|
re-implemented on top of it; the multi-process debugger's tree-wide
|
||||||
this page and the rest of the library reads as convenience
|
REPL lock rides one. Grok this page and the rest of the library reads
|
||||||
wrappers B)
|
as convenience wrappers B)
|
||||||
|
|
||||||
The endpoint contract
|
The endpoint contract
|
||||||
---------------------
|
---------------------
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,9 @@ clan shares one registry with zero config on your part.
|
||||||
The bootstrap rule inside ``open_root_actor()`` is delightfully
|
The bootstrap rule inside ``open_root_actor()`` is delightfully
|
||||||
simple:
|
simple:
|
||||||
|
|
||||||
- on boot, ping every socket addr in ``registry_addrs``; when none
|
- on boot, probe every addr in ``registry_addrs`` with a bounded
|
||||||
are passed the per-transport defaults are used: for TCP the
|
Tractor ``Aid`` handshake; when none are passed the per-transport
|
||||||
|
defaults are used: for TCP the
|
||||||
loopback ``('127.0.0.1', 1616)``, for UDS a
|
loopback ``('127.0.0.1', 1616)``, for UDS a
|
||||||
``registry@1616.sock`` file,
|
``registry@1616.sock`` file,
|
||||||
|
|
||||||
|
|
@ -53,9 +54,11 @@ simple:
|
||||||
actor and register with the *existing* registry; your own IPC
|
actor and register with the *existing* registry; your own IPC
|
||||||
server binds random same-transport addrs instead,
|
server binds random same-transport addrs instead,
|
||||||
|
|
||||||
- if **nothing answers, congratulations: you just became the
|
- if every address is absent, congratulations: you just became the
|
||||||
registrar**. Your transport server binds the registry addrs
|
registrar. Your transport server binds the registry addrs
|
||||||
themselves and you start serving lookups for everyone else.
|
themselves and you start serving lookups for everyone else,
|
||||||
|
- if no registrar answers but an address is occupied by a foreign or
|
||||||
|
non-responsive endpoint, startup fails instead of binding over it.
|
||||||
|
|
||||||
Pass ``ensure_registry=True`` when your program *requires* being
|
Pass ``ensure_registry=True`` when your program *requires* being
|
||||||
the one-and-only registrar; boot then fails loudly with a
|
the one-and-only registrar; boot then fails loudly with a
|
||||||
|
|
@ -196,9 +199,10 @@ the existing registrar:
|
||||||
|
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
Per the bootstrap rules above, if the registrar at those addrs is
|
Per the bootstrap rules above, if those addrs are absent this process
|
||||||
*not* reachable this process simply becomes its own (registrar)
|
becomes its own registrar root, so the same code works standalone and
|
||||||
root — so the same code works standalone and as a tree-joiner.
|
as a tree-joiner. An occupied address that does not complete a Tractor
|
||||||
|
registrar handshake fails startup instead of being rebound.
|
||||||
|
|
||||||
"Arbiter"? A legacy naming note
|
"Arbiter"? A legacy naming note
|
||||||
-------------------------------
|
-------------------------------
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ docs; what you read is what CI runs).
|
||||||
Roughly in "first date to long term relationship"
|
Roughly in "first date to long term relationship"
|
||||||
order,
|
order,
|
||||||
|
|
||||||
- :doc:`spawning` — actor nurseries, daemons +
|
- :doc:`spawning` — actor nurseries, daemons,
|
||||||
one-shot workers, process lifetimes.
|
``to_actor.run()`` one-shots and process lifetimes.
|
||||||
- :doc:`rpc` — portals: calling into another
|
- :doc:`rpc` — portals: calling into another
|
||||||
process like it's a local ``await``.
|
process like it's a local ``await``.
|
||||||
- :doc:`context` — the cross-actor task-pair
|
- :doc:`context` — the cross-actor task-pair
|
||||||
|
|
|
||||||
|
|
@ -119,15 +119,16 @@ Run a func in a process
|
||||||
|
|
||||||
Even a pool can be overkill; "run this one async func in a
|
Even a pool can be overkill; "run this one async func in a
|
||||||
subprocess and give me the result" is a one-liner via
|
subprocess and give me the result" is a one-liner via
|
||||||
:meth:`tractor.ActorNursery.run_in_actor`,
|
:func:`tractor.to_actor.run`,
|
||||||
|
|
||||||
.. literalinclude:: ../../examples/parallelism/single_func.py
|
.. literalinclude:: ../../examples/parallelism/single_func.py
|
||||||
:caption: examples/parallelism/single_func.py
|
:caption: examples/parallelism/single_func.py
|
||||||
:language: python
|
:language: python
|
||||||
|
|
||||||
``run_in_actor()`` is a *convenience wrapper* — spawn an actor, run
|
``to_actor.run()`` is a *convenience wrapper* — spawn an actor,
|
||||||
exactly one task in it, reap on result — not the core spawning
|
run exactly one task in it, block on and return its result, reap
|
||||||
model (that's :meth:`tractor.ActorNursery.start_actor` plus
|
— not the core spawning model (that's
|
||||||
|
:meth:`tractor.ActorNursery.start_actor` plus
|
||||||
:meth:`tractor.Portal.open_context`; see :doc:`/guide/context`).
|
:meth:`tractor.Portal.open_context`; see :doc:`/guide/context`).
|
||||||
But for this fire-and-collect shape it's exactly the right amount
|
But for this fire-and-collect shape it's exactly the right amount
|
||||||
of typing.
|
of typing.
|
||||||
|
|
|
||||||
|
|
@ -80,28 +80,56 @@ One special namespace exists: ``'self'`` resolves to the remote
|
||||||
how internal machinery (cancel requests, registry ops) travels;
|
how internal machinery (cancel requests, registry ops) travels;
|
||||||
don't build your app on it.
|
don't build your app on it.
|
||||||
|
|
||||||
One-shot results: ``wait_for_result()``
|
One-shot subactors: ``to_actor.run()``
|
||||||
---------------------------------------
|
--------------------------------------
|
||||||
A portal returned from
|
When the call should own a fresh subactor whose entire job is one
|
||||||
:meth:`~tractor.ActorNursery.run_in_actor` has exactly one
|
function call, :func:`tractor.to_actor.run` spawns it, runs the task,
|
||||||
"main" task running remotely; that task's ``return`` value is
|
returns its result and reaps the process — all in one blocking call:
|
||||||
delivered as the portal's *final result*:
|
|
||||||
|
|
||||||
.. code:: python
|
.. code:: python
|
||||||
|
|
||||||
portal = await an.run_in_actor(fib, n=10)
|
from functools import partial
|
||||||
final = await portal.wait_for_result()
|
|
||||||
|
final = await tractor.to_actor.run(
|
||||||
|
partial(fib, n=10),
|
||||||
|
an=an,
|
||||||
|
)
|
||||||
|
|
||||||
Semantics worth knowing:
|
Semantics worth knowing:
|
||||||
|
|
||||||
- it blocks until the remote task returns, re-raising any
|
- it blocks until the remote task returns, re-raising any
|
||||||
remote error in the usual boxed form.
|
remote error in the usual boxed form right in the calling
|
||||||
- once resolved it's idempotent: later calls return the same
|
task.
|
||||||
cached value.
|
- lifetime mode also determines process ownership: ``an=`` spawns and
|
||||||
- a *daemon* portal (from ``start_actor()``) has no main task,
|
reaps a fresh child in an existing actor nursery, while passing
|
||||||
so there's no final result to wait for: you'll get a warning
|
neither does the same in a private call-scoped nursery (booting
|
||||||
plus a ``NoResult`` sentinel. Results of individual daemon
|
the runtime if needed). ``portal=`` instead runs one linked task
|
||||||
calls come straight back from each ``await portal.run()``.
|
in an existing actor; it neither spawns nor reaps that actor, so
|
||||||
|
the portal's owner remains responsible for its lifetime.
|
||||||
|
- concurrency composes the plain ``trio`` way: schedule
|
||||||
|
multiple ``run()`` calls into a local task nursery (see
|
||||||
|
``examples/parallelism/concurrent_toactor_primes.py``).
|
||||||
|
|
||||||
|
A reused actor must expose both the target module and the
|
||||||
|
``to_actor`` context trampoline:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
portal = await an.start_actor(
|
||||||
|
'worker',
|
||||||
|
enable_modules=[
|
||||||
|
__name__,
|
||||||
|
tractor.to_actor.MODULE,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
final = await tractor.to_actor.run(
|
||||||
|
partial(fib, n=10),
|
||||||
|
portal=portal,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
Pure RPC daemons: ``run_daemon()``
|
Pure RPC daemons: ``run_daemon()``
|
||||||
----------------------------------
|
----------------------------------
|
||||||
|
|
@ -147,7 +175,8 @@ call tears down the entire sub-tree — SC, transitively.
|
||||||
|
|
||||||
When to graduate to ``Context``
|
When to graduate to ``Context``
|
||||||
-------------------------------
|
-------------------------------
|
||||||
``portal.run()`` is great for one-shot, request-response calls.
|
The :meth:`~tractor.Portal.run` method is great for one-shot,
|
||||||
|
request-response calls.
|
||||||
Reach for :meth:`~tractor.Portal.open_context` with an
|
Reach for :meth:`~tractor.Portal.open_context` with an
|
||||||
``@tractor.context`` endpoint as soon as you want:
|
``@tractor.context`` endpoint as soon as you want:
|
||||||
|
|
||||||
|
|
@ -160,10 +189,15 @@ Reach for :meth:`~tractor.Portal.open_context` with an
|
||||||
:meth:`~tractor.Portal.cancel_actor` nukes the **entire**
|
:meth:`~tractor.Portal.cancel_actor` nukes the **entire**
|
||||||
remote runtime and its process.
|
remote runtime and its process.
|
||||||
|
|
||||||
In fact the source plans for ``Portal.run()`` itself to be
|
:func:`tractor.to_actor.run` already enters the full
|
||||||
rebuilt on top of ``open_context()`` — contexts *are* the core
|
:meth:`~tractor.Portal.open_context` lifecycle. The older
|
||||||
inter-actor protocol. Take the full tour in
|
:meth:`~tractor.Portal.run` path instead uses the ``Context`` returned
|
||||||
:doc:`/guide/context`.
|
by the lower-level ``Actor.start_remote_task()`` directly, avoiding a
|
||||||
|
``Started`` handshake but owning less lifecycle machinery. A follow-up
|
||||||
|
should factor their shared linked-task lifecycle without requiring
|
||||||
|
``Portal.run()`` to delegate through the public context API or add
|
||||||
|
another wire message. Take the full tour in
|
||||||
|
:doc:`the context guide </guide/context>`.
|
||||||
|
|
||||||
.. seealso::
|
.. seealso::
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,31 +91,34 @@ somebody-ing:
|
||||||
|
|
||||||
What's going on here?
|
What's going on here?
|
||||||
|
|
||||||
- ``start_actor('frank', enable_modules=[__name__])`` forks off
|
- :meth:`~tractor.ActorNursery.start_actor` forks off
|
||||||
a new process, boots a ``tractor`` runtime inside it, and
|
a new process, boots a ``tractor`` runtime inside it, and
|
||||||
allows it to serve functions from the current module (see the
|
allows it to serve functions from the current module (see the
|
||||||
allowlist section below).
|
allowlist section below).
|
||||||
- each ``await portal.run(...)`` schedules a *new* task in
|
- each :meth:`~tractor.Portal.run` call schedules a *new* task in
|
||||||
frank's task tree and waits on its result — the full RPC story
|
frank's task tree and waits on its result — the full RPC story
|
||||||
lives in :doc:`/guide/rpc`.
|
lives in :doc:`/guide/rpc`.
|
||||||
- frank has no main task to complete, so without the final
|
- frank has no main task to complete, so without the final
|
||||||
``await portal.cancel_actor()`` the nursery block would wait
|
:meth:`~tractor.Portal.cancel_actor` call the nursery block would
|
||||||
on him **forever**. Daemon lifetimes are *yours* to end; that
|
wait on him **forever**. Daemon lifetimes are *yours* to end;
|
||||||
explicitness is the point.
|
that explicitness is the point.
|
||||||
|
|
||||||
``run_in_actor()``: quick one-shot parallelism
|
``to_actor.run()``: quick one-shot parallelism
|
||||||
----------------------------------------------
|
----------------------------------------------
|
||||||
:meth:`~tractor.ActorNursery.run_in_actor` is the convenience
|
Without ``portal=``, :func:`tractor.to_actor.run` is the convenience
|
||||||
wrapper: spawn an actor, run exactly one async function in it,
|
wrapper: spawn an actor, run exactly one async function in it, block
|
||||||
then reap the process as soon as the result arrives.
|
on the result, then reap the process — the distributed sibling of
|
||||||
|
``trio.to_thread.run_sync()``.
|
||||||
|
|
||||||
.. code:: python
|
.. code:: python
|
||||||
|
|
||||||
async with tractor.open_nursery() as an:
|
async with (
|
||||||
portal = await an.run_in_actor(burn_cpu)
|
tractor.open_nursery() as an,
|
||||||
|
trio.open_nursery() as tn,
|
||||||
|
):
|
||||||
# burn rubber in the parent too...
|
# burn rubber in the parent too...
|
||||||
await burn_cpu()
|
tn.start_soon(burn_cpu)
|
||||||
total = await portal.wait_for_result()
|
total = await tractor.to_actor.run(burn_cpu, an=an)
|
||||||
|
|
||||||
A few details worth knowing:
|
A few details worth knowing:
|
||||||
|
|
||||||
|
|
@ -123,43 +126,61 @@ A few details worth knowing:
|
||||||
``name='something_cuter'``.
|
``name='something_cuter'``.
|
||||||
- the function's module is auto-added to the child's
|
- the function's module is auto-added to the child's
|
||||||
``enable_modules`` allowlist.
|
``enable_modules`` allowlist.
|
||||||
- extra ``**kwargs`` are forwarded to the function itself.
|
- targets cross IPC as ``module:name`` references, so portable calls
|
||||||
- the child is *auto-cancelled* once its "main" result lands;
|
use module-global async functions or ``functools.partial`` objects
|
||||||
at nursery exit these run-once children are always reaped
|
wrapping them. Nested functions, methods and callable objects do not
|
||||||
first (causality_ is paramount!).
|
provide that stable address.
|
||||||
|
- target arguments are positional; use ``functools.partial()``
|
||||||
|
to bind target keyword arguments. Keywords passed directly to
|
||||||
|
``run()`` configure actor placement and spawning.
|
||||||
|
- the call blocks until the result (or error) lands and the
|
||||||
|
child is *auto-cancelled* (reaped) right after — so remote
|
||||||
|
errors raise directly in your calling task (causality_ is
|
||||||
|
paramount!).
|
||||||
|
- "placement" composes: ``an=`` spawns a call-owned child from an
|
||||||
|
existing actor nursery, while passing neither opens a private
|
||||||
|
call-scoped nursery. ``portal=`` instead reuses an existing actor:
|
||||||
|
the call scopes only its linked remote task, neither spawns nor
|
||||||
|
reaps the actor, and leaves its lifetime with the portal's owner.
|
||||||
|
That actor must expose both the target module and
|
||||||
|
``tractor.to_actor.MODULE``.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
``run_in_actor()`` is a convenience, **not** the core model.
|
:func:`tractor.to_actor.run` is a convenience, **not** the core
|
||||||
The source literally marks it for an eventual rebuild as
|
model. For actor-owning placements it combines
|
||||||
a thin "hilevel" wrapper on top of
|
:meth:`~tractor.ActorNursery.start_actor`, a linked
|
||||||
:meth:`~tractor.Portal.open_context` (the modern inter-actor
|
:meth:`~tractor.Portal.open_context` call, and per-child
|
||||||
task API). Teach your fingers to use it for quick
|
cancellation/reaping. With ``portal=`` it uses only the linked
|
||||||
fire-and-collect parallelism — think a per-function
|
context call and leaves the existing actor's lifetime untouched.
|
||||||
trio-parallel_ style one-shot — and reach for
|
Teach your fingers to use it for quick
|
||||||
``start_actor()`` + ``open_context()`` for anything
|
fire-and-collect parallelism — think a per-function trio-parallel_
|
||||||
long-lived, stateful or streaming
|
style one-shot — and reach for
|
||||||
(:doc:`/guide/context`).
|
:meth:`~tractor.ActorNursery.start_actor` plus
|
||||||
|
:meth:`~tractor.Portal.open_context` for anything long-lived,
|
||||||
|
stateful or streaming; see :doc:`/guide/context`.
|
||||||
|
|
||||||
Actor lifetimes and teardown order
|
Actor lifetimes and teardown order
|
||||||
----------------------------------
|
----------------------------------
|
||||||
So we have two lifetime flavors:
|
There are two actor-lifetime flavors:
|
||||||
|
|
||||||
- **run-once** (``run_in_actor()``): lives exactly as long as
|
- **call-owned one-shot** (``to_actor.run()`` without ``portal=``):
|
||||||
its single task; reaped the moment its result (or error)
|
spawned for one task, then cancelled and joined before ``run()``
|
||||||
arrives.
|
returns its result or raises its error.
|
||||||
- **daemon** (``start_actor()``): lives until *someone* cancels
|
- **caller-owned daemon** (:meth:`~tractor.ActorNursery.start_actor`),
|
||||||
it — an explicit ``await portal.cancel_actor()``, a bulk
|
including an actor later reused through
|
||||||
``await an.cancel()``, or the one-cancels-all strategy kicking
|
``to_actor.run(..., portal=portal)``: lives until *someone*
|
||||||
in on error.
|
cancels it via an explicit
|
||||||
|
:meth:`~tractor.Portal.cancel_actor`, a bulk
|
||||||
|
:meth:`~tractor.ActorNursery.cancel`, or the one-cancels-all
|
||||||
|
strategy kicking in on error.
|
||||||
|
|
||||||
On a clean exit of the nursery block the teardown order is:
|
On a clean exit of the nursery block the teardown order is:
|
||||||
|
|
||||||
1. the nursery waits on every run-once actor's final result;
|
1. call-owned actors do not survive their own ``to_actor.run()``
|
||||||
any errors from these are raised immediately so your code
|
calls; each is reaped before its call returns.
|
||||||
(acting as supervisor) gets first crack at handling them.
|
2. the nursery waits on caller-owned daemon actors
|
||||||
2. then it waits on daemon actors — **indefinitely**. If you
|
**indefinitely**. If you spawned one, you own its lifetime.
|
||||||
spawned a daemon, you own its lifetime.
|
|
||||||
|
|
||||||
When a child *is* cancelled, teardown is graceful-first per SC
|
When a child *is* cancelled, teardown is graceful-first per SC
|
||||||
discipline: the runtime sends an IPC cancel request and gives
|
discipline: the runtime sends an IPC cancel request and gives
|
||||||
|
|
|
||||||
|
|
@ -185,8 +185,10 @@ first with a bounded grace window — so actor runtimes can run
|
||||||
their ``trio`` teardown paths — escalating to ``SIGKILL`` only as
|
their ``trio`` teardown paths — escalating to ``SIGKILL`` only as
|
||||||
a last resort. The ``--shm`` sweep unlinks ``/dev/shm/`` segments
|
a last resort. The ``--shm`` sweep unlinks ``/dev/shm/`` segments
|
||||||
that no live process has open (it leans on psutil_, already in
|
that no live process has open (it leans on psutil_, already in
|
||||||
your dev venv, to check live mappings and fds) and ``--uds``
|
your dev venv, to check live mappings and fds) and ``--uds`` clears
|
||||||
clears socket files whose binder pid is dead.
|
dead-binder sockets from Tractor's platform-specific runtime dir. It
|
||||||
|
also unconditionally removes ``registry@1616.sock``; do not run the UDS
|
||||||
|
sweep while a live registrar is serving from that default address.
|
||||||
|
|
||||||
Testing your own ``tractor`` app
|
Testing your own ``tractor`` app
|
||||||
--------------------------------
|
--------------------------------
|
||||||
|
|
|
||||||
|
|
@ -43,24 +43,21 @@ Run it::
|
||||||
What's going on here?
|
What's going on here?
|
||||||
|
|
||||||
- ``trio.run(main)`` starts the **root actor**; the ``tractor``
|
- ``trio.run(main)`` starts the **root actor**; the ``tractor``
|
||||||
runtime boots *implicitly* inside ``tractor.open_nursery()``
|
runtime boots *implicitly* inside this ``tractor.to_actor.run()``
|
||||||
whenever it isn't already up. No special entrypoint, no
|
call because neither ``an=`` nor ``portal=`` was supplied. No
|
||||||
framework takeover - it's just a ``trio`` app,
|
special entrypoint, no framework takeover - it's just a ``trio``
|
||||||
|
app,
|
||||||
- inside ``main()`` a *subactor* is spawned via
|
- inside ``main()`` a *subactor* is spawned via
|
||||||
``ActorNursery.run_in_actor()`` and told to run exactly one
|
``tractor.to_actor.run()`` and told to run exactly one
|
||||||
function: ``cellar_door()``,
|
function: ``cellar_door()``,
|
||||||
- you get back a ``Portal``: your handle for invoking tasks in
|
|
||||||
the new process's (separate!) memory domain. We lean on it
|
|
||||||
much harder in the next section,
|
|
||||||
- the subactor, *some_linguist*, boots a fresh ``trio.run()`` in
|
- the subactor, *some_linguist*, boots a fresh ``trio.run()`` in
|
||||||
a **new process** and executes ``cellar_door()`` as its *main
|
a **new process** and executes ``cellar_door()`` as its linked
|
||||||
task* (note the child proving it is *not* the root with
|
one-shot task (note the child proving it is *not* the root with
|
||||||
``tractor.is_root_process()``), then ships the return value
|
``tractor.is_root_process()``), then ships the return value
|
||||||
back over IPC,
|
back over IPC,
|
||||||
- the parent grabs that *final result* with
|
- the call *blocks* until that final result arrives, then
|
||||||
``await portal.wait_for_result()``, much like you'd expect
|
returns it - causality is preserved: your task only proceeds
|
||||||
from a "future" - except causality is preserved: the nursery
|
once the child is *done*, dead, and reaped.
|
||||||
block only exits once the child is *done*, dead, and reaped.
|
|
||||||
|
|
||||||
.. margin:: Just need a worker pool?
|
.. margin:: Just need a worker pool?
|
||||||
|
|
||||||
|
|
@ -71,17 +68,20 @@ What's going on here?
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
``run_in_actor()`` is the *convenience* wrapper: one-shot
|
Without ``portal=``, ``to_actor.run()`` (parlance of
|
||||||
spawn-run-reap semantics for when a subactor's entire job is
|
``trio.to_thread`` and friends) is the *convenience* wrapper:
|
||||||
a single function call. The core primitives are
|
one-shot spawn-run-reap semantics for when a subactor's entire
|
||||||
``ActorNursery.start_actor()`` (next up) paired with
|
job is a single function call. The core primitives are
|
||||||
``Portal.open_context()`` for full, SC-linked cross-actor
|
:meth:`~tractor.ActorNursery.start_actor` (next up) — which
|
||||||
dialogs - see :doc:`/guide/context`.
|
hands you a ``Portal``, your handle for invoking tasks in the
|
||||||
|
new process's (separate!) memory domain — paired with
|
||||||
|
:meth:`~tractor.Portal.open_context` for full, SC-linked
|
||||||
|
cross-actor dialogs; see :doc:`/guide/context`.
|
||||||
|
|
||||||
Daemon actors and RPC
|
Daemon actors and RPC
|
||||||
---------------------
|
---------------------
|
||||||
A ``run_in_actor()``-spawned actor terminates when its main task
|
A subactor spawned by ``to_actor.run()`` terminates after its lone
|
||||||
returns. But often you want long-lived *daemon* actors instead:
|
task returns. But often you want long-lived *daemon* actors instead:
|
||||||
spawned once, then serving (allowlisted) RPC requests until told
|
spawned once, then serving (allowlisted) RPC requests until told
|
||||||
otherwise. That's ``start_actor()``:
|
otherwise. That's ``start_actor()``:
|
||||||
|
|
||||||
|
|
@ -91,14 +91,17 @@ otherwise. That's ``start_actor()``:
|
||||||
|
|
||||||
Two lifetime rules to internalize:
|
Two lifetime rules to internalize:
|
||||||
|
|
||||||
- a ``run_in_actor()`` actor lives exactly as long as its main
|
- a subactor spawned and owned by ``to_actor.run()`` is cancelled
|
||||||
task; the nursery waits for that function (and thus the
|
and reaped before the call returns its result or raises its error,
|
||||||
process) to complete before unblocking,
|
|
||||||
- a ``start_actor()`` actor *lives forever* - an RPC daemon the
|
- a ``start_actor()`` actor *lives forever* - an RPC daemon the
|
||||||
nursery will happily wait on **indefinitely** - until some
|
nursery will happily wait on **indefinitely** - until some
|
||||||
task explicitly cancels it via ``Portal.cancel_actor()`` (as
|
task explicitly cancels it via ``Portal.cancel_actor()`` (as
|
||||||
above), or its parent nursery is cancelled wholesale.
|
above), or its parent nursery is cancelled wholesale.
|
||||||
|
|
||||||
|
Passing ``portal=`` is different: the call owns only the linked
|
||||||
|
remote task. It neither spawns nor reaps the existing actor; the
|
||||||
|
portal's owner must end that actor's lifetime.
|
||||||
|
|
||||||
.. tip::
|
.. tip::
|
||||||
|
|
||||||
Want your *entire program* to just be a long-lived RPC
|
Want your *entire program* to just be a long-lived RPC
|
||||||
|
|
@ -208,16 +211,20 @@ The script of the scene (runtime ``INFO`` log lines trimmed)::
|
||||||
|
|
||||||
The new tricks in play:
|
The new tricks in play:
|
||||||
|
|
||||||
- two subactors, *donny* and *gretchen*, are each told to run
|
- *donny* and *gretchen* start as daemon actors so each remains alive
|
||||||
``say_hello()`` targeting the *other* by name,
|
while the other discovers it and completes its line,
|
||||||
|
- a local ``trio`` nursery runs both ``Portal.run(say_hello)`` calls
|
||||||
|
concurrently; starting both actors first avoids either reciprocal
|
||||||
|
dialog racing one-shot process reaping,
|
||||||
- ``tractor.wait_for_actor()`` blocks until the named peer has
|
- ``tractor.wait_for_actor()`` blocks until the named peer has
|
||||||
registered with the tree's *registrar* (every actor announces
|
registered with the tree's *registrar* (every actor announces
|
||||||
itself at boot), then yields a ``Portal`` connected
|
itself at boot), then yields a ``Portal`` connected
|
||||||
**directly** to that peer,
|
**directly** to that peer,
|
||||||
- each actor invokes its partner's ``hi()`` over that portal:
|
- each actor invokes its partner's ``hi()`` over that portal:
|
||||||
actor-to-actor RPC with the root merely *directing* - and both
|
actor-to-actor RPC with the root merely *directing* - and each
|
||||||
final lines flow back to ``main()`` via
|
``Portal.run()`` returns its final line directly to ``main()``,
|
||||||
``await portal.wait_for_result()``,
|
- the actor nursery explicitly cancels both daemons only after both
|
||||||
|
dialogs complete,
|
||||||
- ``tractor.log.get_console_log("INFO")`` cranks up runtime
|
- ``tractor.log.get_console_log("INFO")`` cranks up runtime
|
||||||
logging so you can watch the spawn/register/cancel machinery
|
logging so you can watch the spawn/register/cancel machinery
|
||||||
narrate itself; remove it for a quiet set.
|
narrate itself; remove it for a quiet set.
|
||||||
|
|
|
||||||
|
|
@ -21,22 +21,38 @@ async def main():
|
||||||
"""Main tractor entry point, the "master" process (for now
|
"""Main tractor entry point, the "master" process (for now
|
||||||
acts as the "director").
|
acts as the "director").
|
||||||
"""
|
"""
|
||||||
async with tractor.open_nursery() as n:
|
async with tractor.open_nursery() as an:
|
||||||
print("Alright... Action!")
|
print("Alright... Action!")
|
||||||
|
|
||||||
donny = await n.run_in_actor(
|
# both actors wait on (then dial!) the *other*, so each
|
||||||
say_hello,
|
# must outlive both hellos: spawn as daemons, run the
|
||||||
name='donny',
|
# hellos concurrently, reap only once both complete.
|
||||||
# arguments are always named
|
portals: dict[str, tractor.Portal] = {
|
||||||
other_actor='gretchen',
|
name: await an.start_actor(
|
||||||
|
name,
|
||||||
|
enable_modules=[__name__],
|
||||||
)
|
)
|
||||||
gretchen = await n.run_in_actor(
|
for name in ('donny', 'gretchen')
|
||||||
|
}
|
||||||
|
|
||||||
|
async def run_and_print(
|
||||||
|
name: str,
|
||||||
|
other_actor: str,
|
||||||
|
) -> None:
|
||||||
|
print(
|
||||||
|
# RPC through an existing actor's `Portal`.
|
||||||
|
await portals[name].run(
|
||||||
say_hello,
|
say_hello,
|
||||||
name='gretchen',
|
other_actor=other_actor,
|
||||||
other_actor='donny',
|
|
||||||
)
|
)
|
||||||
print(await gretchen.wait_for_result())
|
)
|
||||||
print(await donny.wait_for_result())
|
|
||||||
|
async with trio.open_nursery() as tn:
|
||||||
|
tn.start_soon(run_and_print, 'donny', 'gretchen')
|
||||||
|
tn.start_soon(run_and_print, 'gretchen', 'donny')
|
||||||
|
|
||||||
|
await an.cancel()
|
||||||
|
|
||||||
print("CUTTTT CUUTT CUT!!! Donny!! You're supposed to say...")
|
print("CUTTTT CUUTT CUT!!! Donny!! You're supposed to say...")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,14 @@ async def cellar_door():
|
||||||
async def main():
|
async def main():
|
||||||
"""The main ``tractor`` routine.
|
"""The main ``tractor`` routine.
|
||||||
"""
|
"""
|
||||||
async with tractor.open_nursery() as n:
|
# spawn a subactor, run ``cellar_door()`` as its lone task,
|
||||||
|
# block until its result arrives and the subactor is reaped.
|
||||||
portal = await n.run_in_actor(
|
print(
|
||||||
|
await tractor.to_actor.run(
|
||||||
cellar_door,
|
cellar_door,
|
||||||
name='some_linguist',
|
name='some_linguist',
|
||||||
)
|
)
|
||||||
|
)
|
||||||
# The ``async with`` will unblock here since the 'some_linguist'
|
|
||||||
# actor has completed its main task ``cellar_door``.
|
|
||||||
|
|
||||||
print(await portal.wait_for_result())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ async def movie_theatre_question():
|
||||||
async def main():
|
async def main():
|
||||||
"""The main ``tractor`` routine.
|
"""The main ``tractor`` routine.
|
||||||
"""
|
"""
|
||||||
async with tractor.open_nursery() as n:
|
async with tractor.open_nursery() as an:
|
||||||
|
|
||||||
portal = await n.start_actor(
|
portal = await an.start_actor(
|
||||||
'frank',
|
'frank',
|
||||||
# enable the actor to run funcs from this current module
|
# enable the actor to run funcs from this current module
|
||||||
enable_modules=[__name__],
|
enable_modules=[__name__],
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@ async def stream_forever() -> AsyncIterator[int]:
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
|
||||||
async with tractor.open_nursery() as n:
|
async with tractor.open_nursery() as an:
|
||||||
|
|
||||||
portal = await n.start_actor(
|
portal = await an.start_actor(
|
||||||
'donny',
|
'donny',
|
||||||
enable_modules=[__name__],
|
enable_modules=[__name__],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
|
|
@ -21,26 +23,41 @@ async def breakpoint_forever():
|
||||||
async def spawn_until(depth=0):
|
async def spawn_until(depth=0):
|
||||||
""""A nested nursery that triggers another ``NameError``.
|
""""A nested nursery that triggers another ``NameError``.
|
||||||
"""
|
"""
|
||||||
async with tractor.open_nursery() as n:
|
async with (
|
||||||
|
tractor.open_nursery() as an,
|
||||||
|
trio.open_nursery() as tn,
|
||||||
|
):
|
||||||
if depth < 1:
|
if depth < 1:
|
||||||
|
|
||||||
await n.run_in_actor(breakpoint_forever)
|
tn.start_soon(
|
||||||
|
partial(
|
||||||
p = await n.run_in_actor(
|
tractor.to_actor.run,
|
||||||
name_error,
|
breakpoint_forever,
|
||||||
name='name_error'
|
an=an,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Let the background one-shot enter `breakpoint_forever()`
|
||||||
|
# before its sibling raises and cancellation propagates.
|
||||||
await trio.sleep(0.5)
|
await trio.sleep(0.5)
|
||||||
# rx and propagate error from child
|
# rx and propagate error from child
|
||||||
await p.result()
|
await tractor.to_actor.run(
|
||||||
|
name_error,
|
||||||
|
an=an,
|
||||||
|
name='name_error',
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# recusrive call to spawn another process branching layer of
|
# recusrive call to spawn another process branching layer of
|
||||||
# the tree
|
# the tree; blocks (up) each level until the leaf's
|
||||||
|
# `name_error` relays through.
|
||||||
depth -= 1
|
depth -= 1
|
||||||
await n.run_in_actor(
|
await tractor.to_actor.run(
|
||||||
|
partial(
|
||||||
spawn_until,
|
spawn_until,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
name=f'spawn_until_{depth}',
|
name=f'spawn_until_{depth}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -65,34 +82,37 @@ async def main():
|
||||||
└─ python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...)
|
└─ python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
async with tractor.open_nursery(
|
async with (
|
||||||
|
tractor.open_nursery(
|
||||||
debug_mode=True,
|
debug_mode=True,
|
||||||
loglevel='pdb',
|
loglevel='pdb',
|
||||||
) as n:
|
) as an,
|
||||||
|
trio.open_nursery() as tn,
|
||||||
# spawn both actors
|
):
|
||||||
portal = await n.run_in_actor(
|
# spawn both spawner trees as concurrent one-shots; the
|
||||||
|
# first tree's (relayed) error cancels the other.
|
||||||
|
tn.start_soon(
|
||||||
|
partial(
|
||||||
|
tractor.to_actor.run,
|
||||||
|
partial(
|
||||||
spawn_until,
|
spawn_until,
|
||||||
depth=3,
|
depth=3,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
name='spawner0',
|
name='spawner0',
|
||||||
)
|
)
|
||||||
portal1 = await n.run_in_actor(
|
)
|
||||||
|
tn.start_soon(
|
||||||
|
partial(
|
||||||
|
tractor.to_actor.run,
|
||||||
|
partial(
|
||||||
spawn_until,
|
spawn_until,
|
||||||
depth=4,
|
depth=4,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
name='spawner1',
|
name='spawner1',
|
||||||
)
|
)
|
||||||
|
)
|
||||||
# TODO: test this case as well where the parent don't see
|
|
||||||
# the sub-actor errors by default and instead expect a user
|
|
||||||
# ctrl-c to kill the root.
|
|
||||||
with trio.move_on_after(3):
|
|
||||||
await trio.sleep_forever()
|
|
||||||
|
|
||||||
# gah still an issue here.
|
|
||||||
await portal.result()
|
|
||||||
|
|
||||||
# should never get here
|
|
||||||
await portal1.result()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,12 @@ async def name_error():
|
||||||
async def spawn_error():
|
async def spawn_error():
|
||||||
""""A nested nursery that triggers another ``NameError``.
|
""""A nested nursery that triggers another ``NameError``.
|
||||||
"""
|
"""
|
||||||
async with tractor.open_nursery() as n:
|
async with tractor.open_nursery() as an:
|
||||||
portal = await n.run_in_actor(
|
return await tractor.to_actor.run(
|
||||||
name_error,
|
name_error,
|
||||||
|
an=an,
|
||||||
name='name_error_1',
|
name='name_error_1',
|
||||||
)
|
)
|
||||||
return await portal.result()
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
|
@ -38,29 +38,36 @@ async def main():
|
||||||
- root actor should then fail on assert
|
- root actor should then fail on assert
|
||||||
- program termination
|
- program termination
|
||||||
"""
|
"""
|
||||||
async with tractor.open_nursery(
|
async with (
|
||||||
|
tractor.open_nursery(
|
||||||
debug_mode=True,
|
debug_mode=True,
|
||||||
loglevel='devx',
|
loglevel='devx',
|
||||||
) as n:
|
) as an,
|
||||||
|
trio.open_nursery() as tn,
|
||||||
|
):
|
||||||
|
# spawn both actors..
|
||||||
|
portal = await an.start_actor(
|
||||||
|
'name_error',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
portal1 = await an.start_actor(
|
||||||
|
'spawn_error',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
|
||||||
# spawn both actors
|
# ..and bg-schedule their erroring tasks.
|
||||||
portal = await n.run_in_actor(
|
tn.start_soon(portal.run, name_error)
|
||||||
name_error,
|
tn.start_soon(portal1.run, spawn_error)
|
||||||
name='name_error',
|
|
||||||
)
|
# yield to the bg tasks so both RPC requests are
|
||||||
portal1 = await n.run_in_actor(
|
# submitted (and start crashing) before the root's own
|
||||||
spawn_error,
|
# error below (the legacy `run_in_actor()` submitted
|
||||||
name='spawn_error',
|
# in-line with each spawn).
|
||||||
)
|
await trio.sleep(0.5)
|
||||||
|
|
||||||
# trigger a root actor error
|
# trigger a root actor error
|
||||||
assert 0
|
assert 0
|
||||||
|
|
||||||
# attempt to collect results (which raises error in parent)
|
|
||||||
# still has some issues where the parent seems to get stuck
|
|
||||||
await portal.result()
|
|
||||||
await portal1.result()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,12 @@ async def name_error():
|
||||||
async def spawn_error():
|
async def spawn_error():
|
||||||
""""A nested nursery that triggers another ``NameError``.
|
""""A nested nursery that triggers another ``NameError``.
|
||||||
"""
|
"""
|
||||||
async with tractor.open_nursery() as n:
|
async with tractor.open_nursery() as an:
|
||||||
portal = await n.run_in_actor(
|
return await tractor.to_actor.run(
|
||||||
name_error,
|
name_error,
|
||||||
|
an=an,
|
||||||
name='name_error_1',
|
name='name_error_1',
|
||||||
)
|
)
|
||||||
return await portal.result()
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
|
@ -36,17 +36,39 @@ async def main():
|
||||||
`-python -m tractor._child --uid ('spawn_error', '52ee14a5 ...)
|
`-python -m tractor._child --uid ('spawn_error', '52ee14a5 ...)
|
||||||
`-python -m tractor._child --uid ('name_error', '3391222c ...)
|
`-python -m tractor._child --uid ('name_error', '3391222c ...)
|
||||||
"""
|
"""
|
||||||
|
errors: list[BaseException] = []
|
||||||
|
|
||||||
async with tractor.open_nursery(
|
async with tractor.open_nursery(
|
||||||
debug_mode=True,
|
debug_mode=True,
|
||||||
# loglevel='runtime',
|
# loglevel='runtime',
|
||||||
) as n:
|
) as an:
|
||||||
|
|
||||||
# Spawn both actors, don't bother with collecting results
|
async def run_and_collect(fn):
|
||||||
# (would result in a different debugger outcome due to parent's
|
'''
|
||||||
# cancellation).
|
One-shot whose (boxed) error is stashed instead of
|
||||||
await n.run_in_actor(breakpoint_forever)
|
raised so a sibling's crash never cancels the others
|
||||||
await n.run_in_actor(name_error)
|
before they've had their own debugger sessions (the
|
||||||
await n.run_in_actor(spawn_error)
|
"collect all errors" the legacy `run_in_actor()` API
|
||||||
|
did implicitly at nursery teardown).
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
await tractor.to_actor.run(fn, an=an)
|
||||||
|
except tractor.RemoteActorError as rae:
|
||||||
|
errors.append(rae)
|
||||||
|
|
||||||
|
# Spawn all one-shot task actors, collecting (vs.
|
||||||
|
# raising) their errors.
|
||||||
|
async with trio.open_nursery() as tn:
|
||||||
|
tn.start_soon(run_and_collect, breakpoint_forever)
|
||||||
|
tn.start_soon(run_and_collect, name_error)
|
||||||
|
tn.start_soon(run_and_collect, spawn_error)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise BaseExceptionGroup(
|
||||||
|
'multi_subactors errored!',
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ async def main() -> None:
|
||||||
|
|
||||||
async with tractor.open_nursery(
|
async with tractor.open_nursery(
|
||||||
debug_mode=True,
|
debug_mode=True,
|
||||||
) as n:
|
) as an:
|
||||||
portal = await n.start_actor(
|
portal = await an.start_actor(
|
||||||
'ctx_child',
|
'ctx_child',
|
||||||
|
|
||||||
# XXX: we don't enable the current module in order
|
# XXX: we don't enable the current module in order
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,14 @@ async def die():
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
async with tractor.open_nursery() as tn:
|
async with tractor.open_nursery() as an:
|
||||||
|
|
||||||
debug_actor = await tn.start_actor(
|
debug_actor = await an.start_actor(
|
||||||
'debugged_boi',
|
'debugged_boi',
|
||||||
enable_modules=[__name__],
|
enable_modules=[__name__],
|
||||||
debug_mode=True,
|
debug_mode=True,
|
||||||
)
|
)
|
||||||
crash_boi = await tn.start_actor(
|
crash_boi = await an.start_actor(
|
||||||
'crash_boi',
|
'crash_boi',
|
||||||
enable_modules=[__name__],
|
enable_modules=[__name__],
|
||||||
# debug_mode=True,
|
# debug_mode=True,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
|
|
@ -10,15 +12,17 @@ async def name_error():
|
||||||
async def spawn_until(depth=0):
|
async def spawn_until(depth=0):
|
||||||
""""A nested nursery that triggers another ``NameError``.
|
""""A nested nursery that triggers another ``NameError``.
|
||||||
"""
|
"""
|
||||||
async with tractor.open_nursery() as n:
|
async with tractor.open_nursery() as an:
|
||||||
if depth < 1:
|
if depth < 1:
|
||||||
# await n.run_in_actor('breakpoint_forever', breakpoint_forever)
|
await tractor.to_actor.run(name_error, an=an)
|
||||||
await n.run_in_actor(name_error)
|
|
||||||
else:
|
else:
|
||||||
depth -= 1
|
depth -= 1
|
||||||
await n.run_in_actor(
|
await tractor.to_actor.run(
|
||||||
|
partial(
|
||||||
spawn_until,
|
spawn_until,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
name=f'spawn_until_{depth}',
|
name=f'spawn_until_{depth}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,28 +41,37 @@ async def main():
|
||||||
└─ python -m tractor._child --uid ('name_error', '6c2733b8 ...)
|
└─ python -m tractor._child --uid ('name_error', '6c2733b8 ...)
|
||||||
|
|
||||||
'''
|
'''
|
||||||
async with tractor.open_nursery(
|
async with (
|
||||||
|
tractor.open_nursery(
|
||||||
debug_mode=True,
|
debug_mode=True,
|
||||||
enable_transports=['uds'], # TODO, apss this via osenv?
|
enable_transports=['uds'], # TODO, pass this via osenv?
|
||||||
loglevel='devx', # XXX, required for test!
|
loglevel='devx', # XXX, required for test!
|
||||||
) as n:
|
) as an,
|
||||||
|
trio.open_nursery() as tn,
|
||||||
# spawn both actors
|
):
|
||||||
portal = await n.run_in_actor(
|
# spawn the deeper tree in the bg..
|
||||||
spawn_until,
|
tn.start_soon(
|
||||||
depth=0,
|
partial(
|
||||||
name='spawner0',
|
tractor.to_actor.run,
|
||||||
)
|
partial(
|
||||||
portal1 = await n.run_in_actor(
|
|
||||||
spawn_until,
|
spawn_until,
|
||||||
depth=1,
|
depth=1,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
name='spawner1',
|
name='spawner1',
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# nursery cancellation should be triggered due to propagated
|
# ..while blocking on the shallow (faster to fail) tree
|
||||||
# error from child.
|
# whose propagated error triggers nursery cancellation.
|
||||||
await portal.result()
|
await tractor.to_actor.run(
|
||||||
await portal1.result()
|
partial(
|
||||||
|
spawn_until,
|
||||||
|
depth=0,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
|
name='spawner0',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,24 @@ async def main():
|
||||||
simultaneously.
|
simultaneously.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
async with tractor.open_nursery(
|
async with (
|
||||||
|
tractor.open_nursery(
|
||||||
debug_mode=True,
|
debug_mode=True,
|
||||||
# loglevel='debug' # ?XXX required?
|
# loglevel='debug' # ?XXX required?
|
||||||
) as n:
|
) as an,
|
||||||
|
trio.open_nursery() as tn,
|
||||||
# spawn both actors
|
):
|
||||||
portal = await n.run_in_actor(key_error)
|
# spawn the actor..
|
||||||
|
portal = await an.start_actor(
|
||||||
|
'key_error',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
print(
|
print(
|
||||||
f'Child is up @ {portal.chan.aid.reprol()}'
|
f'Child is up @ {portal.chan.aid.reprol()}'
|
||||||
)
|
)
|
||||||
|
# ..then schedule its erroring task in the bg while the
|
||||||
|
# root blocks below.
|
||||||
|
tn.start_soon(portal.run, key_error)
|
||||||
|
|
||||||
# XXX: originally a bug caused by this is where root would enter
|
# XXX: originally a bug caused by this is where root would enter
|
||||||
# the debugger and clobber the tty used by the repl even though
|
# the debugger and clobber the tty used by the repl even though
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue