Compare commits
10 Commits
c43df50647
...
645e25c041
| Author | SHA1 | Date |
|---|---|---|
|
|
645e25c041 | |
|
|
d2274d3252 | |
|
|
212a8f82ac | |
|
|
41f34de3a4 | |
|
|
76a65eabb5 | |
|
|
28c7107933 | |
|
|
4cd8192488 | |
|
|
6d53b49bbd | |
|
|
3f6d4cd221 | |
|
|
ff372239b6 |
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-27T23:25:00Z
|
||||||
|
git_ref: d067505a
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260827T232500Z_d067505a_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing the reviewed bindspace-reference changes, the human
|
||||||
|
asked to continue with the next Layer C increment.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added the private Linux network-namespace entry primitive required by
|
||||||
|
future child bootstrap wiring. It verifies the inherited capability
|
||||||
|
before `setns()`, constrains entry to network namespaces, and verifies
|
||||||
|
the resulting process namespace before runtime initialization proceeds.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/spawn/_netns.py` - synchronous verified netns entry.
|
||||||
|
- `tests/test_netns_spawn.py` - stale-FD, syscall and post-entry guards.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - primitive ownership and
|
||||||
|
remaining integration boundary.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human requested the next incremental implementation after accepting
|
||||||
|
the prior split commits. Their established preference for end-to-end
|
||||||
|
tests informed use of real FDs where possible; only privileged `setns()`
|
||||||
|
and procfs observation are replaced to keep the suite unprivileged. In
|
||||||
|
staged review, the human requested the full project license header,
|
||||||
|
explicit comments on both intentional inode mismatches, and independent
|
||||||
|
call traces proving both fake syscall and procfs hooks execute. The
|
||||||
|
agent applied those human-directed revisions; no direct manual source
|
||||||
|
edits were observed.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-27T23:25:00Z
|
||||||
|
git_ref: d067505a
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - verify pre-runtime netns entry
|
||||||
|
|
||||||
|
After committing realized bindspace references and their deferred codec
|
||||||
|
plan, the human requested the next Layer C increment.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_netns.py tests/test_netns_spawn.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added a private synchronous `enter_netns()` bootstrap primitive. It
|
||||||
|
validates the inherited FD and expected inode, constrains `setns()` to
|
||||||
|
`CLONE_NEWNET`, verifies `/proc/self/ns/net` afterward, and leaves FD
|
||||||
|
closure to the future spawn-bootstrap caller.
|
||||||
|
|
||||||
|
Tests use real stand-in FDs while replacing only the privileged syscall
|
||||||
|
and post-entry procfs observation. They prove stale FDs fail before
|
||||||
|
entry, the syscall receives the exact namespace type, and bootstrap
|
||||||
|
rejects an unexpected post-entry namespace. Ruff and all three focused
|
||||||
|
tests passed.
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: wg-netns-bootstrap-entry
|
||||||
|
timestamp: 2026-08-28T01:22:05Z
|
||||||
|
git_ref: 9ae7cd86
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260828T012205Z_9ae7cd86_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After completing and auditing the stacked rebase, the human approved
|
||||||
|
the next WireGuard Layer C slice: integrate the verified network
|
||||||
|
namespace entry primitive into child spawn bootstrap.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Wire an optional child-owned namespace FD and expected inode into both
|
||||||
|
child entrypoints. Keep namespace entry and FD closure synchronous and
|
||||||
|
ordered before actor runtime initialization, while deliberately
|
||||||
|
deferring backend FD transport and public API plumbing.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/spawn/_entry.py` - enter and release an optional inherited
|
||||||
|
namespace capability before multiprocessing child runtime startup.
|
||||||
|
- `tractor/_child.py` - consume the capability at the start of the
|
||||||
|
Trio child bootloader, before patching or actor construction.
|
||||||
|
- `tests/test_netns_spawn.py` - prove entry ordering, failure isolation,
|
||||||
|
and exact FD ownership for both child entrypoints.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected spawn-time namespace integration as the next work
|
||||||
|
slice and authorized implementation. No direct manual source edits
|
||||||
|
were observed during generation.
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-28T01:22:05Z
|
||||||
|
git_ref: 9ae7cd86
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - enter inherited netns during child bootstrap
|
||||||
|
|
||||||
|
The human asked to continue the WireGuard Layer C implementation after
|
||||||
|
the rebased branch landed the verified `spawn._netns.enter_netns()`
|
||||||
|
primitive.
|
||||||
|
|
||||||
|
## Generated code
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/_child.py tractor/spawn/_entry.py tests/test_netns_spawn.py`
|
||||||
|
|
||||||
|
Add one optional atomic `(namespace_fd, expected_inode)` bootstrap
|
||||||
|
capability to the multiprocessing entrypoint and Trio child bootloader.
|
||||||
|
Enter and verify the namespace before Trio patching, actor construction,
|
||||||
|
runtime state, logging, Trio startup, parent connection, sockets, or
|
||||||
|
worker threads, then close the child-owned inherited FD before
|
||||||
|
continuing.
|
||||||
|
|
||||||
|
Add privilege-free direct-entrypoint tests using real stand-in FDs and
|
||||||
|
a fake namespace syscall boundary. Cover helper-level no-op behavior,
|
||||||
|
successful entry ordering, exact FD closure, malformed-FD isolation,
|
||||||
|
primary-error preservation, and failure cleanup for both child
|
||||||
|
bootstrap paths.
|
||||||
|
|
||||||
|
## Scope boundary
|
||||||
|
|
||||||
|
This increment does not transfer FDs through spawn backends or expose a
|
||||||
|
public actor API. Backend-specific FD duplication, bootstrap failure
|
||||||
|
reporting, and root-process namespace entry remain follow-up work.
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: trio-pre-handshake-child-death
|
||||||
|
timestamp: 2026-08-28T04:51:19Z
|
||||||
|
git_ref: fb6d81d3
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260828T045119Z_fb6d81d3_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human approved the next WireGuard Layer C implementation step after
|
||||||
|
committing child-side network namespace bootstrap.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Restore and harden the boot-race design originally implemented by
|
||||||
|
`3b0724eba85b4014170ed95773e1e41a60d5c513` before transferring
|
||||||
|
namespace FDs. Race the initial peer handshake against child-process
|
||||||
|
death so a pre-connection bootstrap failure surfaces instead of
|
||||||
|
parking `ActorNursery.start_actor()` forever.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/spawn/_spawn.py` - provide the generic handshake/process
|
||||||
|
death race.
|
||||||
|
- `tractor/spawn/_trio.py` - use the race during Trio child startup.
|
||||||
|
- `tests/test_spawning.py` - cover handshake-first and death-first
|
||||||
|
schedules, simultaneous outcomes, waiter errors, and backend cleanup.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human approved continued Layer C implementation. The agent narrowed
|
||||||
|
the next slice to failure detection after analysis showed FD transfer
|
||||||
|
would otherwise introduce a parent hang. The human then identified the
|
||||||
|
earlier `wait_for_peer_or_proc_death()` implementation and required
|
||||||
|
explicit credit; the source docstring and commit message now cite its
|
||||||
|
original commit rather than a later cherry-pick. The human directly
|
||||||
|
reflowed `_trio.py` to the project line limit and normalized nearby
|
||||||
|
quotes. The human also required naming the race helper in its lifecycle
|
||||||
|
comment, explicit `trio.Cancelled` handling in waiter tests, expected
|
||||||
|
cancellation diagnostics, and a `trio.Process`-constrained test double.
|
||||||
|
After mainline portability verification, the human chose to commit the
|
||||||
|
fix on the current WireGuard branch and require its cherry-pick onto
|
||||||
|
`main` as a blocker in the eventual WireGuard pull request.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-28T04:51:19Z
|
||||||
|
git_ref: fb6d81d3
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - detect child death before parent handshake
|
||||||
|
|
||||||
|
The human asked to continue WireGuard Layer C after child entrypoints
|
||||||
|
learned to consume inherited network namespace capabilities.
|
||||||
|
|
||||||
|
## Generated code
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_spawn.py tractor/spawn/_trio.py tests/test_spawning.py`
|
||||||
|
|
||||||
|
Race a spawned child's initial parent handshake against its process
|
||||||
|
wait operation. Return the established channel when the handshake wins;
|
||||||
|
raise `ActorFailure` with the exit status when the process dies first.
|
||||||
|
Use the race in the Trio exec backend instead of waiting indefinitely
|
||||||
|
on `IPCServer.wait_for_peer()`.
|
||||||
|
|
||||||
|
Add deterministic Trio tests for handshake-first, death-first,
|
||||||
|
simultaneous, and waiter-error schedules, controlling readiness with
|
||||||
|
events rather than sleeps. Exercise the full Trio backend with a fake
|
||||||
|
dead process to prove pre-publication event registration and exact
|
||||||
|
failed-startup cleanup.
|
||||||
|
|
||||||
|
## Scope boundary
|
||||||
|
|
||||||
|
This prerequisite prevents pre-connection namespace-entry failures
|
||||||
|
from hanging their parent. Namespace FD transfer itself remains the
|
||||||
|
next commit.
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: trio-bindspace-fd-transport
|
||||||
|
timestamp: 2026-08-28T17:29:43Z
|
||||||
|
git_ref: 2ca8c570
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260828T172943Z_2ca8c570_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing generic pre-handshake child-death detection, the human
|
||||||
|
approved the proposed next Layer C slice: transport a live bindspace
|
||||||
|
capability through the Trio spawn backend.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Plumb an optional process-local `Bindspace` through actor spawn APIs.
|
||||||
|
Give a Trio exec child its own inherited namespace descriptor, preserve
|
||||||
|
existing caller `pass_fds`, transport the FD/inode pair through the CLI,
|
||||||
|
and close the temporary parent descriptor on every open-process outcome.
|
||||||
|
Reject unsupported multiprocessing transport explicitly. Exercise the
|
||||||
|
complete Trio path with a real actor that enters a distinct network
|
||||||
|
namespace and reports its namespace and inherited-FD inodes over RPC.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_supervise.py` - accept and relay a child bindspace.
|
||||||
|
- `tractor/to_actor/_api.py` - expose bindspace spawn configuration.
|
||||||
|
- `tractor/spawn/_spawn.py` - relay the bindspace to spawn backends.
|
||||||
|
- `tractor/spawn/_trio.py` - duplicate, transfer, and close the FD.
|
||||||
|
- `tractor/spawn/_mp.py` - reject unsupported MP transport.
|
||||||
|
- `tractor/_child.py` - parse and forward the atomic CLI capability.
|
||||||
|
- `tests/test_netns_spawn.py` - cover transport and cleanup schedules.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human approved the Trio-first transport boundary after reviewing
|
||||||
|
the proposed dependency order, then supplied staged-diff comments across
|
||||||
|
multiple review passes. Human-directed revisions replaced abstract
|
||||||
|
ownership wording with concrete FD and process terminology, clarified
|
||||||
|
descriptor cleanup, preserved existing `pass_fds` explicitly, and
|
||||||
|
replaced the mocked success-path test with a real child actor, real
|
||||||
|
`setns()`, handshake, and RPC round trip inside unprivileged user/network
|
||||||
|
namespaces. Follow-up review added direct parent FD-table verification
|
||||||
|
that the temporary child-bound duplicate is closed after successful
|
||||||
|
spawn.
|
||||||
|
|
||||||
|
Multiprocessing FD reduction and root-process namespace entry remain
|
||||||
|
separate follow-up slices. No direct manual source edits were observed;
|
||||||
|
the review-driven source revisions were applied by the agent.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-28T17:29:43Z
|
||||||
|
git_ref: 2ca8c570
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - transport bindspace FD through Trio spawn
|
||||||
|
|
||||||
|
The human approved the next WireGuard Layer C increment after landing
|
||||||
|
pre-handshake child-death detection.
|
||||||
|
|
||||||
|
## Generated code
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/_child.py tractor/runtime/_supervise.py tractor/spawn/_spawn.py tractor/spawn/_trio.py tractor/spawn/_mp.py tractor/to_actor/_api.py tests/test_netns_spawn.py`
|
||||||
|
|
||||||
|
Thread an optional live `Bindspace` through actor spawn APIs. For the
|
||||||
|
Trio backend, duplicate its namespace FD, append that temporary
|
||||||
|
descriptor to existing `pass_fds`, and send the atomic descriptor/inode
|
||||||
|
pair through the child CLI. Close the parent temporary duplicate as
|
||||||
|
soon as `open_process()` returns, raises, or is cancelled, while the
|
||||||
|
child consumes its inherited descriptor before runtime startup.
|
||||||
|
|
||||||
|
Reject bindspace transport explicitly in multiprocessing backends until
|
||||||
|
their descriptor-reduction slice is implemented. Add privilege-free
|
||||||
|
tests for CLI forwarding, merged pass-FD state, caller immutability,
|
||||||
|
source/duplicate ownership, exec failure, cancellation, absent live
|
||||||
|
authority, death-before-handshake cleanup, and MP rejection.
|
||||||
|
|
||||||
|
## Scope boundary
|
||||||
|
|
||||||
|
Multiprocessing FD reduction and root-process namespace entry remain
|
||||||
|
follow-up work.
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-net-api-extraction
|
||||||
|
timestamp: 2026-08-30T02:52:01Z
|
||||||
|
git_ref: b1f6ade8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260830T025201Z_b1f6ade8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After rebasing the WireGuard Layer C stack, the human questioned the
|
||||||
|
placement of bindspace APIs under `tractor.discovery`, considered new
|
||||||
|
public package names, selected `tractor.net`, and authorized the
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Extracted bindspace and tunnel implementations into a lazy
|
||||||
|
`tractor.net` package. Moved public multiaddr, bindspace, tunnel, and
|
||||||
|
WireGuard symbols out of `tractor.discovery`, updated internal imports,
|
||||||
|
tests, examples, and documentation, and added cold-import regressions
|
||||||
|
that keep optional networking dependencies off the root import path.
|
||||||
|
Current-netns attachment pins `/proc/thread-self/ns/net` so calls from
|
||||||
|
non-leader threads retain the caller's namespace rather than the process
|
||||||
|
leader's.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/net/` - lazy public network API and implementation modules.
|
||||||
|
- `tractor/discovery/` - retain actor discovery and internal address parsing.
|
||||||
|
- `tractor/__init__.py` - expose `tractor.net` lazily.
|
||||||
|
- `tests/net/` - network declaration and lifecycle coverage.
|
||||||
|
- `tests/test_lazy_imports.py` - enforce the lazy package boundary.
|
||||||
|
- `examples/multihost/wg_lan/` - use the public network package.
|
||||||
|
- `docs/` - document network APIs separately from actor discovery.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human rejected `tractor.discovery` as the long-term public home,
|
||||||
|
considered tunnel- and namespace-specific alternatives, and selected
|
||||||
|
the broader `tractor.net` boundary because bindspaces may include plain
|
||||||
|
netns, WireGuard, VRF, veth, and later network resources. The agent
|
||||||
|
applied the resulting source changes; no direct manual edits were
|
||||||
|
observed.
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-30T02:52:01Z
|
||||||
|
git_ref: b1f6ade8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - extract public network APIs
|
||||||
|
|
||||||
|
The human questioned whether bindspace and WireGuard lifecycle APIs
|
||||||
|
belonged under actor discovery, selected the proposed `tractor.net`
|
||||||
|
boundary, and authorized implementation.
|
||||||
|
|
||||||
|
## Generated code
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/net tractor/discovery tractor/__init__.py`
|
||||||
|
|
||||||
|
Move bindspace and tunnel implementations into a lazy public network
|
||||||
|
package. Keep actor discovery focused on registry and lookup behavior,
|
||||||
|
while exposing bindspace, tunnel, WireGuard, and multiaddr declarations
|
||||||
|
through `tractor.net` without loading optional networking dependencies
|
||||||
|
during `import tractor`.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/net tests/test_lazy_imports.py tests/ipc examples/multihost/wg_lan docs`
|
||||||
|
|
||||||
|
Move network-focused tests to `tests/net`, update internal and public
|
||||||
|
imports, verify lazy symbol resolution and removed discovery exports,
|
||||||
|
and document the new package boundary.
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: root-bindspace-bootstrap
|
||||||
|
timestamp: 2026-08-30T02:52:02Z
|
||||||
|
git_ref: b1f6ade8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260830T025202Z_b1f6ade8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human chose to defer multiprocessing bindspace FD transport because
|
||||||
|
that backend may be removed, selected root/single-actor namespace
|
||||||
|
bootstrap as the next stage, approved caller-thread restoration and
|
||||||
|
public `tractor.net.open_wg_bindspace()` composition, and authorized
|
||||||
|
implementation after the stack rebase.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added `bindspace=` to `tractor.open_root_actor()`. Root startup now
|
||||||
|
duplicates and validates the live namespace FD, enters before any
|
||||||
|
debugger, registry, IPC, or actor-runtime work, and synchronously
|
||||||
|
restores the caller thread's original namespace after complete root
|
||||||
|
teardown. Added deterministic failure/cancellation coverage, a real UDS
|
||||||
|
root actor E2E across two network namespaces, and an executable public
|
||||||
|
WireGuard-bindspace/root composition regression. Bound roots reject the
|
||||||
|
persistent `mp_forkserver` backend because a helper from an earlier
|
||||||
|
runtime may retain a stale namespace; the public annotation also remains
|
||||||
|
runtime-resolvable without importing `tractor.net` eagerly.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/_root.py` - public root bindspace lifecycle integration.
|
||||||
|
- `tractor/spawn/_netns.py` - temporary thread-local netns enter/restore.
|
||||||
|
- `tests/test_netns_spawn.py` - deterministic and real root regressions.
|
||||||
|
- `tests/net/test_wg_iface_lifecycle.py` - public root composition test.
|
||||||
|
- `docs/api/net.rst` - root ownership and restoration contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human explicitly deferred multiprocessing support, selected root
|
||||||
|
bootstrap as the next implementation stage, approved the requirement to
|
||||||
|
restore the caller's original namespace so owned teardown can complete,
|
||||||
|
and approved composition through public `tractor.net` contexts rather
|
||||||
|
than a new one-shot convenience API. The human later rewrote
|
||||||
|
`_enter_netns_temporarily()` to separate restoration from FD cleanup and
|
||||||
|
make the transition flow easier to inspect. The agent retained that
|
||||||
|
intent with nested lexical FD ownership while fixing setup-error masking,
|
||||||
|
conditional restoration, and duplicate-FD cleanup found during the
|
||||||
|
requested audit.
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-30T02:52:02Z
|
||||||
|
git_ref: b1f6ade8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - bootstrap the root actor in a bindspace
|
||||||
|
|
||||||
|
The human deferred multiprocessing descriptor transport, selected root
|
||||||
|
namespace bootstrap as the next Layer C stage, approved enter/restore
|
||||||
|
semantics and public `tractor.net` composition, and authorized
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
## Generated code
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/_root.py tractor/spawn/_netns.py`
|
||||||
|
|
||||||
|
Add an optional realized `Bindspace` to `open_root_actor()`. Duplicate
|
||||||
|
its namespace FD, enter before debugger, registry, IPC, or runtime
|
||||||
|
startup, and restore the caller thread's original namespace after root
|
||||||
|
teardown. Preserve caller FD ownership and primary errors across
|
||||||
|
restoration and close failures.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/test_netns_spawn.py tests/net/test_wg_iface_lifecycle.py docs/api/net.rst`
|
||||||
|
|
||||||
|
Exercise same-netns behavior, body errors, cancellation, restoration
|
||||||
|
failures, missing and closed FDs, real root entry/restoration under an
|
||||||
|
unprivileged user namespace, and public WireGuard-bindspace/root
|
||||||
|
composition.
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: dependency-neutral-discovery-types
|
||||||
|
timestamp: 2026-09-03T20:15:30Z
|
||||||
|
git_ref: d097090f
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260903T201530Z_d097090f_discovery_types.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The maintainer rejected annotation-only model names rebound to `Any`,
|
||||||
|
questioned the self-referential `get_type_hints()` regression, selected a
|
||||||
|
dependency-neutral `tractor.discovery.types` module as the preferred
|
||||||
|
direction, and then requested implementation of that first design layer.
|
||||||
|
|
||||||
|
The maintainer separately asked how to handle the disliked staged
|
||||||
|
`tractor/net/_bootstrap.py` implementation. That file was excluded from
|
||||||
|
this implementation boundary and retained as review evidence.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added a dependency-neutral module for canonical primitive address
|
||||||
|
descriptors and separate structural contracts for concrete transport
|
||||||
|
addresses and tunnel-capable address declarations. Migrated discovery,
|
||||||
|
IPC, root, runtime, and tunnel annotations to import those real types,
|
||||||
|
removing every runtime model-name assignment to `Any`.
|
||||||
|
|
||||||
|
Kept parser table aliases beside `parse_endpoints()` instead of turning
|
||||||
|
the new module into a general alias registry. Kept transport-specific
|
||||||
|
factories on TCP and UDS classes because their accepted descriptor and
|
||||||
|
bindspace types differ. Replaced the old `get_type_hints()` assertions
|
||||||
|
with cold-import and canonical-identity coverage.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/types.py` - dependency-neutral descriptors and
|
||||||
|
address protocols.
|
||||||
|
- `tractor/discovery/_addr.py` - imports and re-exports canonical types;
|
||||||
|
retains concrete factory registry.
|
||||||
|
- `tractor/discovery/_multiaddr.py` - uses the structural declaration
|
||||||
|
type and runtime-safe parsed table alias.
|
||||||
|
- `tractor/ipc/_tcp.py` - imports the real TCP descriptor alias.
|
||||||
|
- `tractor/ipc/_uds.py` - imports the real Unix descriptor alias.
|
||||||
|
- `tractor/ipc/_transport.py` - imports the concrete-address protocol
|
||||||
|
without cycling through `_addr`.
|
||||||
|
- `tractor/ipc/_chan.py` - accepts structural address declarations
|
||||||
|
without a `TunnelledAddress = Any` fallback.
|
||||||
|
- `tractor/ipc/_server.py` - retains declaration metadata structurally
|
||||||
|
and routes reconstruction through the concrete address registry.
|
||||||
|
- `tractor/net/_tunnel.py` - imports real dependency-neutral address
|
||||||
|
types while retaining dynamic `Any` only for external netlink data.
|
||||||
|
- `tractor/_root.py` - removes the `Bindspace = Any` fallback and uses
|
||||||
|
concrete address-factory dispatch.
|
||||||
|
- `tractor/runtime/_runtime.py` - infers concrete registry address types
|
||||||
|
for random listener allocation.
|
||||||
|
- `tests/test_lazy_imports.py` - replaces workaround introspection with
|
||||||
|
dependency and canonical-identity checks.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The maintainer identified the `Any` aliases and their introspection test
|
||||||
|
as an unjustified circular contract, directed investigation of the real
|
||||||
|
cycle/import-performance cause, selected `tractor.discovery.types`, and
|
||||||
|
approved beginning implementation. The maintainer also required that the
|
||||||
|
current bootstrap implementation not be treated as acceptable design and
|
||||||
|
provided direct review annotations which remain preserved in the
|
||||||
|
worktree.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-09-03T20:15:30Z
|
||||||
|
git_ref: d097090f
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Implemented a dependency-neutral discovery typing layer after reviewing
|
||||||
|
the address, transport, tunnel, and lazy-import dependency graph.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/types.py tractor/discovery/_addr.py tractor/discovery/_multiaddr.py tractor/ipc/_tcp.py tractor/ipc/_uds.py tractor/ipc/_transport.py tractor/ipc/_chan.py tractor/ipc/_server.py tractor/net/_tunnel.py tractor/_root.py tractor/runtime/_runtime.py tests/test_lazy_imports.py`
|
||||||
|
|
||||||
|
The generated changes move primitive address descriptors and structural
|
||||||
|
address protocols into `tractor.discovery.types`. Concrete transport
|
||||||
|
factories remain outside the shared protocol because TCP and UDS accept
|
||||||
|
different descriptor and bindspace inputs. Runtime names are no longer
|
||||||
|
rebound to `Any` merely to satisfy `typing.get_type_hints()`.
|
||||||
|
|
||||||
|
The old annotation-introspection regression was replaced with coverage
|
||||||
|
that checks dependency-neutral cold imports and canonical type identity
|
||||||
|
across discovery, IPC, and tunnel modules.
|
||||||
|
|
||||||
|
Verification completed with the worktree-local Python 3.13 environment:
|
||||||
|
|
||||||
|
- focused address/lazy/tunnel coverage: 69 passed;
|
||||||
|
- discovery, IPC, and local coverage: 77 passed, 2 xpassed;
|
||||||
|
- root/runtime coverage: 6 passed with 24 multiprocessing fork
|
||||||
|
deprecation warnings.
|
||||||
|
|
||||||
|
Ruff was unavailable in the existing `py313` environment. Scoped
|
||||||
|
`git diff --check` passed. The global whitespace check reports only a
|
||||||
|
pre-existing maintainer annotation in `tractor/net/_bootstrap.py`, which
|
||||||
|
was deliberately left untouched.
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_fa04e8396ffeEUnWUrYF734CSM
|
||||||
|
timestamp: 2026-09-07T01:15:33Z
|
||||||
|
git_ref: 5966e8aa
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260907T011533Z_5966e8aa_frozen_process_local.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After landing the dependency-neutral discovery typing layer, the
|
||||||
|
maintainer approved isolating `FrozenProcessLocal` as the next clean
|
||||||
|
prerequisite before revisiting bindspace and bootstrap design.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added and publicly exported the immutable process-local marker. Moved
|
||||||
|
its behavioral coverage out of dependence on bootstrap models by adding
|
||||||
|
focused test structs and regressions to the existing process-local test
|
||||||
|
module. The tests demonstrate default wire rejection, frozen struct
|
||||||
|
fields, shallow nested mutability, rejection of sentinel replacement and
|
||||||
|
`omit_defaults=True`, and guarded subclass validation hooks which cannot
|
||||||
|
replace the sentinel.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/msg/_local.py` - immutable process-local marker.
|
||||||
|
- `tractor/msg/__init__.py` - public marker export.
|
||||||
|
- `tests/msg/test_process_local.py` - independent marker contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The maintainer rejected incremental salvage of the current bootstrap
|
||||||
|
planner, approved `FrozenProcessLocal` as an independent prerequisite,
|
||||||
|
and directed implementation before the bindspace re-evaluation. Existing
|
||||||
|
bootstrap review annotations and artifacts remain untouched.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-09-07T01:15:33Z
|
||||||
|
git_ref: 5966e8aa
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Implemented `FrozenProcessLocal` as an independent prerequisite rather
|
||||||
|
than coupling its contract to the rejected bootstrap planner.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/msg/_local.py tractor/msg/__init__.py tests/msg/test_process_local.py`
|
||||||
|
|
||||||
|
The marker inherits `ProcessLocal` wire rejection and msgspec's frozen
|
||||||
|
field behavior. It is exported from `tractor.msg`, while focused tests
|
||||||
|
prove direct and nested encoding rejection, field immutability, and the
|
||||||
|
intentional shallow treatment of referenced mutable values.
|
||||||
|
|
||||||
|
Construction now rejects sentinel replacement and `omit_defaults=True`,
|
||||||
|
and wraps subclass validation hooks with checks before and after their
|
||||||
|
execution so they cannot skip or replace the base marker.
|
||||||
|
|
||||||
|
Verification completed with 44 process-local, WG-config, bindspace, and
|
||||||
|
tunnel-address tests passing. Ruff and scoped whitespace checks also
|
||||||
|
passed.
|
||||||
|
|
@ -47,7 +47,7 @@ Here is a small example from piker,
|
||||||
We should take whatever common API is needed to support this and
|
We should take whatever common API is needed to support this and
|
||||||
distill it into a
|
distill it into a
|
||||||
```python
|
```python
|
||||||
tractor.discovery.parse_endpoints(
|
tractor.net.parse_endpoints(
|
||||||
) -> dict[
|
) -> dict[
|
||||||
str,
|
str,
|
||||||
list[Address]
|
list[Address]
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ examples in gh #482) used a *suffix* form
|
||||||
`/ip4/10.0.11.1/tcp/1616/wg/u<key>`. That parses, but it is
|
`/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
|
semantically inverted: it puts the overlay addr where the bearer
|
||||||
belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares
|
belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares
|
||||||
no overlay endpoint at all. `tractor.discovery.parse_wg_maddr()`
|
no overlay endpoint at all. `tractor.net.parse_wg_maddr()`
|
||||||
now rejects it with an actionable error.
|
now rejects it with an actionable error.
|
||||||
Observed protocol-name lists, for writing the `match`:
|
Observed protocol-name lists, for writing the `match`:
|
||||||
|
|
||||||
|
|
@ -203,7 +203,7 @@ Observed protocol-name lists, for writing the `match`:
|
||||||
### 3.3 pure parser helpers + explicit verification
|
### 3.3 pure parser helpers + explicit verification
|
||||||
|
|
||||||
The parser/key-codec helpers and async production verifier live in
|
The parser/key-codec helpers and async production verifier live in
|
||||||
`tractor/discovery/_tunnel.py`; parsing remains pure while verification
|
`tractor/net/_tunnel.py`; parsing remains pure while verification
|
||||||
is an explicit, impure caller step:
|
is an explicit, impure caller step:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|
@ -620,6 +620,12 @@ server bound in the old namespace.
|
||||||
- the child spawn/bootstrap trampoline calls `setns()` **before**
|
- the child spawn/bootstrap trampoline calls `setns()` **before**
|
||||||
`_runtime.async_main()`, `IPCServer.listen_on()`, parent-channel
|
`_runtime.async_main()`, `IPCServer.listen_on()`, parent-channel
|
||||||
connection, or creation of any worker thread/socket.
|
connection, or creation of any worker thread/socket.
|
||||||
|
- `spawn._netns.enter_netns()` is the first private bootstrap
|
||||||
|
primitive: it checks the inherited FD against the expected inode,
|
||||||
|
calls `setns(fd, CLONE_NEWNET)`, and verifies
|
||||||
|
`/proc/self/ns/net` before returning. It deliberately does not own
|
||||||
|
or close the FD; spawn propagation and status reporting remain the
|
||||||
|
caller's next integration boundary.
|
||||||
- only after successful entry does the child drop namespace-entry
|
- only after successful entry does the child drop namespace-entry
|
||||||
privileges and initialize the actor runtime.
|
privileges and initialize the actor runtime.
|
||||||
- a root/single-actor process follows the same ordering: enter during
|
- a root/single-actor process follows the same ordering: enter during
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ Most-used names at a glance:
|
||||||
core
|
core
|
||||||
context
|
context
|
||||||
discovery
|
discovery
|
||||||
|
net
|
||||||
errors
|
errors
|
||||||
msg
|
msg
|
||||||
trionics
|
trionics
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ already distributed-system aware.
|
||||||
.. seealso::
|
.. seealso::
|
||||||
|
|
||||||
:doc:`/explain/architecture` for the transport/server
|
:doc:`/explain/architecture` for the transport/server
|
||||||
internals, :doc:`/api/discovery` for how channel addresses
|
internals, :doc:`/api/net` for network declarations,
|
||||||
get registered and found, and :doc:`/api/msg` for the codec
|
:doc:`/api/discovery` for how channel addresses get registered
|
||||||
layer every channel speaks.
|
and found, and :doc:`/api/msg` for the codec layer every channel
|
||||||
|
speaks.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
Network declarations and lifecycles
|
||||||
|
===================================
|
||||||
|
|
||||||
|
``tractor.net`` provides address composition, bindspace declarations,
|
||||||
|
and tunnel configuration. The package is lazy: importing
|
||||||
|
``tractor`` or ``tractor.net`` does not load multiaddr, WireGuard, or
|
||||||
|
pyroute2 implementation modules until a public symbol is used.
|
||||||
|
|
||||||
|
Multiaddr helpers
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
.. currentmodule:: tractor.net
|
||||||
|
|
||||||
|
.. autofunction:: mk_maddr
|
||||||
|
|
||||||
|
.. autofunction:: parse_maddr
|
||||||
|
|
||||||
|
.. autofunction:: parse_endpoints
|
||||||
|
|
||||||
|
Bindspaces
|
||||||
|
----------
|
||||||
|
|
||||||
|
.. autoclass:: BindspaceSpec
|
||||||
|
|
||||||
|
.. autoclass:: BindspaceRef
|
||||||
|
|
||||||
|
.. autoclass:: Bindspace
|
||||||
|
|
||||||
|
.. autofunction:: attach_netns
|
||||||
|
|
||||||
|
.. autofunction:: open_netns
|
||||||
|
|
||||||
|
.. autofunction:: open_bindspace
|
||||||
|
|
||||||
|
Root actor composition
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
A live :class:`Bindspace` can scope the root actor itself. Compose the
|
||||||
|
bindspace manager outside :func:`tractor.open_root_actor` so its network
|
||||||
|
namespace remains pinned for the complete actor runtime::
|
||||||
|
|
||||||
|
async with tractor.net.open_wg_bindspace(
|
||||||
|
bindspace_spec=bindspace_spec,
|
||||||
|
layers=layers,
|
||||||
|
role='listen',
|
||||||
|
) as bindspace:
|
||||||
|
async with tractor.open_root_actor(
|
||||||
|
bindspace=bindspace,
|
||||||
|
enable_transports=['uds'],
|
||||||
|
) as root_actor:
|
||||||
|
...
|
||||||
|
|
||||||
|
Root entry happens before registry probes, IPC listeners, runtime sockets,
|
||||||
|
or actor startup. On every exit, including cancellation or a body error,
|
||||||
|
the calling thread is restored to its original network namespace before
|
||||||
|
``open_root_actor()`` returns. The root context duplicates the live
|
||||||
|
``Bindspace.namespace_fd`` and never consumes or closes the descriptor
|
||||||
|
owned by ``open_wg_bindspace()``. Default child processes inherit the root
|
||||||
|
namespace naturally; passing an explicit alternate child ``bindspace``
|
||||||
|
continues to use that spawn backend's existing behavior. Bound roots reject
|
||||||
|
the persistent ``mp_forkserver`` backend because a server started by an
|
||||||
|
earlier runtime may retain that runtime's network namespace.
|
||||||
|
|
||||||
|
This is the current low-level composition API. A future convenience API
|
||||||
|
may accept a tunnel-bearing multiaddr, realize its WireGuard bindspace
|
||||||
|
internally, and supply that live capability to root startup.
|
||||||
|
|
||||||
|
Tunnels and WireGuard
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
.. autoclass:: TunnelledAddress
|
||||||
|
|
||||||
|
.. autoclass:: WGTunnelSpec
|
||||||
|
|
||||||
|
.. autoclass:: WGInterfaceConfig
|
||||||
|
|
||||||
|
.. autoclass:: WGPeerConfig
|
||||||
|
|
||||||
|
.. autofunction:: parse_wg_maddr
|
||||||
|
|
||||||
|
.. autofunction:: mk_wg_maddr
|
||||||
|
|
||||||
|
.. autofunction:: strip_tunnels
|
||||||
|
|
||||||
|
.. autofunction:: tunnels_of
|
||||||
|
|
||||||
|
.. autofunction:: open_wg_iface
|
||||||
|
|
||||||
|
.. autofunction:: open_wg_bindspace
|
||||||
|
|
||||||
|
.. autofunction:: read_wg_pubkey
|
||||||
|
|
||||||
|
.. autofunction:: read_wg_peers
|
||||||
|
|
||||||
|
.. autofunction:: verify_wg_peer
|
||||||
|
|
@ -264,7 +264,7 @@ terminology is retired: it's *registrar*/*registry* everywhere now
|
||||||
substitute "registrar" and you're up to date.
|
substitute "registrar" and you're up to date.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Multihoming nerds: ``tractor.discovery`` also ships
|
Multihoming nerds: ``tractor.net`` ships
|
||||||
libp2p-style *multiaddr* helpers — ``mk_maddr()`` and
|
libp2p-style *multiaddr* helpers — ``mk_maddr()`` and
|
||||||
``parse_maddr()`` — for describing transport endpoints as
|
``parse_maddr()`` — for describing transport endpoints as
|
||||||
structured strings.
|
structured strings.
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ ping -c1 10.0.11.1 # from B
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -c "
|
python -c "
|
||||||
from tractor.discovery import mb_pubkey
|
from tractor.net import mb_pubkey
|
||||||
key = open('wg_pub.key').read().strip()
|
key = open('wg_pub.key').read().strip()
|
||||||
print(mb_pubkey(key))
|
print(mb_pubkey(key))
|
||||||
"
|
"
|
||||||
|
|
@ -198,9 +198,23 @@ Four corrections, all from
|
||||||
`_addr_to_transport` wants a `MsgTransport` per addr-type,
|
`_addr_to_transport` wants a `MsgTransport` per addr-type,
|
||||||
which `wg` doesn't have.
|
which `wg` doesn't have.
|
||||||
|
|
||||||
## next
|
## root composition
|
||||||
|
|
||||||
Layer A's `TunnelledAddress` and native maddr parser plus Layer B's
|
The `TunnelledAddress`, native maddr parser, bindspace lifecycle, and
|
||||||
explicit pyroute2 verification now live in `tractor.discovery`. Next,
|
explicit pyroute2 verification APIs live in `tractor.net`. Keep the
|
||||||
add `open_bindspace()` `@acm`s which create/tear down the iface and
|
owning bindspace context outside the root actor so its namespace FD
|
||||||
netns.
|
remains live through complete actor teardown:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with tractor.net.open_wg_bindspace(
|
||||||
|
bindspace_spec,
|
||||||
|
layers,
|
||||||
|
role='listen',
|
||||||
|
) as bindspace:
|
||||||
|
async with tractor.open_root_actor(bindspace=bindspace):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
The root actor enters before registry or IPC setup and restores the
|
||||||
|
calling thread's original namespace before the outer bindspace context
|
||||||
|
removes owned WireGuard and netns resources.
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
from tractor.discovery import (
|
from tractor.net import (
|
||||||
TunnelledAddress,
|
TunnelledAddress,
|
||||||
mk_maddr,
|
mk_maddr,
|
||||||
parse_wg_maddr,
|
parse_wg_maddr,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
from tractor.discovery import (
|
from tractor.net import (
|
||||||
TunnelledAddress,
|
TunnelledAddress,
|
||||||
parse_wg_maddr,
|
parse_wg_maddr,
|
||||||
verify_wg_peer,
|
verify_wg_peer,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from tractor.devx import dump_on_hang
|
||||||
from tractor.trionics import collapse_eg
|
from tractor.trionics import collapse_eg
|
||||||
from tractor._testing import tractor_test
|
from tractor._testing import tractor_test
|
||||||
from tractor.discovery._addr import wrap_address
|
from tractor.discovery._addr import wrap_address
|
||||||
from tractor.discovery._multiaddr import mk_maddr
|
from tractor.net import mk_maddr
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ from tractor.discovery import _api
|
||||||
from tractor.discovery._addr import (
|
from tractor.discovery._addr import (
|
||||||
wrap_address,
|
wrap_address,
|
||||||
)
|
)
|
||||||
from tractor.discovery._multiaddr import mk_maddr
|
from tractor.net import mk_maddr
|
||||||
from tractor.ipc import _connect_chan
|
from tractor.ipc import _connect_chan
|
||||||
from tractor._testing.addr import get_rando_addr
|
from tractor._testing.addr import get_rando_addr
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from __future__ import annotations
|
||||||
import pytest
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from tractor.discovery import (
|
from tractor.net import (
|
||||||
TunnelledAddress,
|
TunnelledAddress,
|
||||||
WGTunnelSpec,
|
WGTunnelSpec,
|
||||||
tunnels_of,
|
tunnels_of,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from tractor.discovery import (
|
from tractor.net import (
|
||||||
BindspaceRef,
|
BindspaceRef,
|
||||||
TunnelledAddress,
|
TunnelledAddress,
|
||||||
WGTunnelSpec,
|
WGTunnelSpec,
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,100 @@ from __future__ import annotations
|
||||||
import msgspec
|
import msgspec
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tractor.msg import ProcessLocal
|
from tractor.msg import (
|
||||||
|
FrozenProcessLocal,
|
||||||
|
ProcessLocal,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LocalHandle(ProcessLocal):
|
class LocalHandle(ProcessLocal):
|
||||||
'''
|
'''
|
||||||
Minimal process-local struct used to exercise the global marker.
|
Default marker whose sentinel must fail direct and nested
|
||||||
|
encoding.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
resource_id: int
|
resource_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class FrozenLocalHandle(FrozenProcessLocal):
|
||||||
|
'''
|
||||||
|
Frozen outer fields whose referenced `labels` list remains
|
||||||
|
mutable.
|
||||||
|
|
||||||
|
Supplying `_process_local=None` would also replace the
|
||||||
|
unsupported sentinel with an encodable value if construction did
|
||||||
|
not reject it.
|
||||||
|
|
||||||
|
'''
|
||||||
|
resource_id: int
|
||||||
|
# Mutating this list distinguishes shallow struct freezing from
|
||||||
|
# recursively freezing every referenced object.
|
||||||
|
labels: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# This class option would omit the default-valued sentinel from
|
||||||
|
# msgpack.
|
||||||
|
class OmittingLocalHandle(
|
||||||
|
ProcessLocal,
|
||||||
|
omit_defaults=True,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Encoder config which would drop the default-valued sentinel.
|
||||||
|
|
||||||
|
With `omit_defaults=True`, both `_process_local` and
|
||||||
|
`resource_id`
|
||||||
|
equal their defaults, so msgspec could encode this struct as an
|
||||||
|
empty map without ever traversing `_ProcessLocalToken`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
resource_id: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class ValidatedLocalHandle(ProcessLocal):
|
||||||
|
'''
|
||||||
|
Subclass validator which would shadow the marker's post-init
|
||||||
|
hook.
|
||||||
|
|
||||||
|
`ProcessLocal.__init_subclass__()` must wrap this method so
|
||||||
|
sentinel validation still runs without requiring a cooperative
|
||||||
|
`super()` call, while retaining this resource-id validation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
resource_id: int
|
||||||
|
|
||||||
|
# Defining this hook normally shadows an inherited
|
||||||
|
# `__post_init__`; `ProcessLocal` must wrap it rather than rely
|
||||||
|
# on a `super()` call.
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.resource_id < 0:
|
||||||
|
raise ValueError('resource_id must be non-negative')
|
||||||
|
|
||||||
|
|
||||||
|
class MutatingFrozenLocalHandle(FrozenProcessLocal):
|
||||||
|
'''
|
||||||
|
Hook which uses msgspec's frozen-field escape hatch on the
|
||||||
|
sentinel.
|
||||||
|
|
||||||
|
A check only before this hook would miss the replacement and
|
||||||
|
leave the completed struct encodable, so the wrapper must check
|
||||||
|
again after subclass validation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
resource_id: int
|
||||||
|
|
||||||
|
# Replacing the sentinel inside this hook defeats a pre-hook-only
|
||||||
|
# check even though the struct is frozen.
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
# This is msgspec's supported internal mutation path for
|
||||||
|
# frozen structs, and therefore the strongest sentinel-
|
||||||
|
# replacement case.
|
||||||
|
msgspec.structs.force_setattr(
|
||||||
|
self,
|
||||||
|
'_process_local',
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
'nested',
|
'nested',
|
||||||
(
|
(
|
||||||
|
|
@ -55,3 +138,74 @@ def test_process_local_rejects_default_encoding(
|
||||||
match='_ProcessLocalToken.*unsupported',
|
match='_ProcessLocalToken.*unsupported',
|
||||||
):
|
):
|
||||||
msgspec.msgpack.encode(value)
|
msgspec.msgpack.encode(value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_frozen_process_local_contract() -> None:
|
||||||
|
'''
|
||||||
|
Preserve wire rejection while freezing process-local struct
|
||||||
|
fields.
|
||||||
|
|
||||||
|
A plain frozen msgspec struct could otherwise cross actor IPC.
|
||||||
|
Build one `FrozenProcessLocal` with a mutable referenced list,
|
||||||
|
then prove direct and nested default encoding still reach
|
||||||
|
`_ProcessLocalToken`. Reject field replacement while allowing
|
||||||
|
mutation owned by the nested list, demonstrating that the marker
|
||||||
|
provides shallow immutability.
|
||||||
|
|
||||||
|
'''
|
||||||
|
handle = FrozenLocalHandle(
|
||||||
|
resource_id=1,
|
||||||
|
labels=['initial'],
|
||||||
|
)
|
||||||
|
|
||||||
|
for value in (
|
||||||
|
handle,
|
||||||
|
{'nested': [handle]},
|
||||||
|
):
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError,
|
||||||
|
match='_ProcessLocalToken.*unsupported',
|
||||||
|
):
|
||||||
|
msgspec.msgpack.encode(value)
|
||||||
|
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
handle.resource_id = 2 # type: ignore[misc]
|
||||||
|
|
||||||
|
handle.labels.append('changed')
|
||||||
|
assert handle.labels == ['initial', 'changed']
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_local_rejects_sentinel_bypasses() -> None:
|
||||||
|
'''
|
||||||
|
Prevent constructor and encoder options from removing the
|
||||||
|
sentinel.
|
||||||
|
|
||||||
|
`FrozenLocalHandle` supplies an encodable sentinel replacement;
|
||||||
|
`OmittingLocalHandle` asks msgspec to omit the default sentinel;
|
||||||
|
`ValidatedLocalHandle` shadows the inherited post-init hook; and
|
||||||
|
`MutatingFrozenLocalHandle` replaces the sentinel from inside
|
||||||
|
that hook. Prove construction rejects each wire-safety bypass
|
||||||
|
while the ordinary subclass value validator still executes.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with pytest.raises(TypeError, match='internal sentinel'):
|
||||||
|
FrozenLocalHandle(
|
||||||
|
resource_id=1,
|
||||||
|
labels=[],
|
||||||
|
_process_local=None, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match='omit_defaults'):
|
||||||
|
OmittingLocalHandle()
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match='internal sentinel'):
|
||||||
|
ValidatedLocalHandle(
|
||||||
|
resource_id=1,
|
||||||
|
_process_local=None, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='must be non-negative'):
|
||||||
|
ValidatedLocalHandle(resource_id=-1)
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match='internal sentinel'):
|
||||||
|
MutatingFrozenLocalHandle(resource_id=1)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
'''Network declaration and lifecycle tests.'''
|
||||||
|
|
@ -13,7 +13,7 @@ import msgspec
|
||||||
import pytest
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from tractor.discovery import (
|
from tractor.net import (
|
||||||
Bindspace,
|
Bindspace,
|
||||||
BindspaceOwnership,
|
BindspaceOwnership,
|
||||||
BindspaceRef,
|
BindspaceRef,
|
||||||
|
|
@ -23,7 +23,7 @@ from tractor.discovery import (
|
||||||
open_bindspace,
|
open_bindspace,
|
||||||
open_netns,
|
open_netns,
|
||||||
)
|
)
|
||||||
from tractor.discovery import _bindspace
|
from tractor.net import _bindspace
|
||||||
from tractor.msg import ProcessLocal
|
from tractor.msg import ProcessLocal
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -261,10 +261,11 @@ def test_open_bindspace_attaches_current_netns() -> None:
|
||||||
'''
|
'''
|
||||||
The unnamed spec must borrow and pin the caller's current netns.
|
The unnamed spec must borrow and pin the caller's current netns.
|
||||||
|
|
||||||
Open `/proc/self/ns/net`, prove the yielded bindspace records its
|
Opening `/proc/self/ns/net` could pin the thread-group leader's
|
||||||
stable inode and borrowed ownership, then exit the context and
|
namespace when this context runs from another thread. Prove the
|
||||||
prove the exact descriptor was closed without altering the
|
implementation selects `/proc/thread-self/ns/net`, records the
|
||||||
namespace itself.
|
calling thread's stable inode and borrowed ownership, then closes
|
||||||
|
the exact descriptor without altering the namespace itself.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
async def main() -> int:
|
async def main() -> int:
|
||||||
|
|
@ -276,6 +277,9 @@ def test_open_bindspace_attaches_current_netns() -> None:
|
||||||
kind='netns',
|
kind='netns',
|
||||||
)
|
)
|
||||||
assert spec.key is CURRENT_NETNS
|
assert spec.key is CURRENT_NETNS
|
||||||
|
assert _bindspace._THREAD_NETNS == Path(
|
||||||
|
'/proc/thread-self/ns/net'
|
||||||
|
)
|
||||||
async with open_bindspace(spec) as bindspace:
|
async with open_bindspace(spec) as bindspace:
|
||||||
namespace_fd: int|None = bindspace.namespace_fd
|
namespace_fd: int|None = bindspace.namespace_fd
|
||||||
assert namespace_fd is not None
|
assert namespace_fd is not None
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'''
|
'''
|
||||||
Multiaddr construction, parsing, and round-trip tests for
|
Multiaddr construction, parsing, and round-trip tests for
|
||||||
`tractor.discovery._multiaddr.mk_maddr()` and
|
`tractor.net.mk_maddr()` and `tractor.net.parse_maddr()`.
|
||||||
`tractor.discovery._multiaddr.parse_maddr()`.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -10,20 +9,20 @@ from types import SimpleNamespace
|
||||||
import pytest
|
import pytest
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
|
|
||||||
from tractor.discovery import (
|
from tractor.net import (
|
||||||
TunnelledAddress,
|
TunnelledAddress,
|
||||||
WGTunnelSpec,
|
WGTunnelSpec,
|
||||||
mb_pubkey,
|
mb_pubkey,
|
||||||
mk_wg_maddr,
|
mk_wg_maddr,
|
||||||
|
mk_maddr,
|
||||||
|
parse_endpoints,
|
||||||
|
parse_maddr,
|
||||||
parse_wg_maddr,
|
parse_wg_maddr,
|
||||||
tunnels_of,
|
tunnels_of,
|
||||||
)
|
)
|
||||||
from tractor.ipc._tcp import TCPAddress
|
from tractor.ipc._tcp import TCPAddress
|
||||||
from tractor.ipc._uds import UDSAddress
|
from tractor.ipc._uds import UDSAddress
|
||||||
from tractor.discovery._multiaddr import (
|
from tractor.discovery._multiaddr import (
|
||||||
mk_maddr,
|
|
||||||
parse_maddr,
|
|
||||||
parse_endpoints,
|
|
||||||
_tpt_proto_to_maddr,
|
_tpt_proto_to_maddr,
|
||||||
_maddr_to_tpt_proto,
|
_maddr_to_tpt_proto,
|
||||||
)
|
)
|
||||||
|
|
@ -14,7 +14,7 @@ from __future__ import annotations
|
||||||
import msgspec
|
import msgspec
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tractor.discovery import (
|
from tractor.net import (
|
||||||
BindspaceRef,
|
BindspaceRef,
|
||||||
TunnelledAddress,
|
TunnelledAddress,
|
||||||
WGTunnelSpec,
|
WGTunnelSpec,
|
||||||
|
|
@ -7,7 +7,7 @@ from __future__ import annotations
|
||||||
import msgspec
|
import msgspec
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tractor.discovery import (
|
from tractor.net import (
|
||||||
WGInterfaceConfig,
|
WGInterfaceConfig,
|
||||||
WGPeerConfig,
|
WGPeerConfig,
|
||||||
)
|
)
|
||||||
|
|
@ -8,12 +8,14 @@ from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager as acm
|
from contextlib import asynccontextmanager as acm
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
from typing import BinaryIO
|
from typing import BinaryIO
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from tractor.discovery import (
|
import tractor
|
||||||
|
from tractor.net import (
|
||||||
Bindspace,
|
Bindspace,
|
||||||
BindspaceRef,
|
BindspaceRef,
|
||||||
BindspaceSpec,
|
BindspaceSpec,
|
||||||
|
|
@ -23,7 +25,7 @@ from tractor.discovery import (
|
||||||
open_wg_bindspace,
|
open_wg_bindspace,
|
||||||
open_wg_iface,
|
open_wg_iface,
|
||||||
)
|
)
|
||||||
from tractor.discovery import _tunnel
|
from tractor.net import _tunnel
|
||||||
|
|
||||||
|
|
||||||
_LOCAL_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
_LOCAL_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
||||||
|
|
@ -243,6 +245,8 @@ def test_open_wg_iface_shields_cancelled_cleanup(
|
||||||
|
|
||||||
|
|
||||||
def test_open_wg_bindspace_nests_resource_lifetimes(
|
def test_open_wg_bindspace_nests_resource_lifetimes(
|
||||||
|
tmp_path: Path,
|
||||||
|
request: pytest.FixtureRequest,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
@ -268,14 +272,16 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
|
||||||
bindspace_spec: BindspaceSpec = BindspaceSpec(
|
bindspace_spec: BindspaceSpec = BindspaceSpec(
|
||||||
kind='netns',
|
kind='netns',
|
||||||
)
|
)
|
||||||
|
token = (tmp_path / 'netns').open('w+b')
|
||||||
|
request.addfinalizer(token.close)
|
||||||
bindspace: Bindspace = Bindspace(
|
bindspace: Bindspace = Bindspace(
|
||||||
spec=bindspace_spec,
|
spec=bindspace_spec,
|
||||||
ref=BindspaceRef(
|
ref=BindspaceRef(
|
||||||
kind='netns',
|
kind='netns',
|
||||||
key=None,
|
key=None,
|
||||||
inode=1,
|
inode=os.fstat(token.fileno()).st_ino,
|
||||||
),
|
),
|
||||||
namespace_fd=None,
|
namespace_fd=token.fileno(),
|
||||||
ownership='borrowed',
|
ownership='borrowed',
|
||||||
)
|
)
|
||||||
outer_spec: WGTunnelSpec = WGTunnelSpec(
|
outer_spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
|
@ -376,3 +382,105 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
|
||||||
'wg-outer-exit',
|
'wg-outer-exit',
|
||||||
'bindspace-exit',
|
'bindspace-exit',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='network namespaces are Linux-only',
|
||||||
|
)
|
||||||
|
def test_public_wg_bindspace_scopes_child_actor(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tpt_proto: str,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Public network contexts must fully enclose supervised children.
|
||||||
|
|
||||||
|
Attach the real current netns through `tractor.net`, fake only WG
|
||||||
|
interface provisioning, and supervise a child in the yielded
|
||||||
|
`Bindspace`. The nursery reaps it before network-resource teardown.
|
||||||
|
|
||||||
|
'''
|
||||||
|
events: list[str] = []
|
||||||
|
bindspace_spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
lifecycle='attach',
|
||||||
|
)
|
||||||
|
tunnel_spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey=_PEER_KEY,
|
||||||
|
iface='wg-root',
|
||||||
|
)
|
||||||
|
config: WGInterfaceConfig = WGInterfaceConfig(
|
||||||
|
private_key=_LOCAL_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def fake_open_wg_iface(
|
||||||
|
spec: WGTunnelSpec,
|
||||||
|
iface_config: WGInterfaceConfig,
|
||||||
|
bindspace: Bindspace,
|
||||||
|
role: _tunnel.WGRole,
|
||||||
|
) -> AsyncIterator[WGTunnelSpec]:
|
||||||
|
'''
|
||||||
|
Trace one WG layer around the real root actor lifetime.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert spec is tunnel_spec
|
||||||
|
assert iface_config is config
|
||||||
|
assert role == 'listen'
|
||||||
|
assert bindspace.namespace_fd is not None
|
||||||
|
events.append('wg-enter')
|
||||||
|
try:
|
||||||
|
yield spec
|
||||||
|
finally:
|
||||||
|
events.append('wg-exit')
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_tunnel,
|
||||||
|
'open_wg_iface',
|
||||||
|
fake_open_wg_iface,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Compose the public network and root actor context managers.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with tractor.net.open_wg_bindspace(
|
||||||
|
bindspace_spec=bindspace_spec,
|
||||||
|
layers=((tunnel_spec, config),),
|
||||||
|
role='listen',
|
||||||
|
) as bindspace:
|
||||||
|
events.append('bindspace-open')
|
||||||
|
async with tractor.open_root_actor(
|
||||||
|
start_method='trio',
|
||||||
|
enable_transports=[tpt_proto],
|
||||||
|
):
|
||||||
|
events.append('root-open')
|
||||||
|
async with tractor.open_nursery() as nursery:
|
||||||
|
portal = await nursery.start_actor(
|
||||||
|
'scoped-worker',
|
||||||
|
bindspace=bindspace,
|
||||||
|
parent_addr=tractor.current_actor().accept_addr,
|
||||||
|
registry_addrs=tractor.current_actor().reg_addrs,
|
||||||
|
)
|
||||||
|
await portal.cancel_actor()
|
||||||
|
assert bindspace.namespace_fd is not None
|
||||||
|
assert os.fstat(
|
||||||
|
bindspace.namespace_fd,
|
||||||
|
).st_ino == bindspace.ref.inode
|
||||||
|
assert Path(
|
||||||
|
'/proc/thread-self/ns/net'
|
||||||
|
).stat().st_ino == bindspace.ref.inode
|
||||||
|
events.append('root-closed')
|
||||||
|
|
||||||
|
events.append('bindspace-closed')
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
assert events == [
|
||||||
|
'wg-enter',
|
||||||
|
'bindspace-open',
|
||||||
|
'root-open',
|
||||||
|
'root-closed',
|
||||||
|
'wg-exit',
|
||||||
|
'bindspace-closed',
|
||||||
|
]
|
||||||
|
|
@ -13,13 +13,13 @@ from typing import (
|
||||||
import pytest
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from tractor.discovery import (
|
from tractor.net import (
|
||||||
read_wg_peers,
|
read_wg_peers,
|
||||||
read_wg_pubkey,
|
read_wg_pubkey,
|
||||||
verify_wg_peer,
|
verify_wg_peer,
|
||||||
WGTunnelSpec,
|
WGTunnelSpec,
|
||||||
)
|
)
|
||||||
from tractor.discovery import _tunnel
|
from tractor.net import _tunnel
|
||||||
|
|
||||||
pyroute2: Any = pytest.importorskip('pyroute2')
|
pyroute2: Any = pytest.importorskip('pyroute2')
|
||||||
|
|
||||||
|
|
@ -7,20 +7,6 @@ import os
|
||||||
from statistics import median
|
from statistics import median
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from typing import (
|
|
||||||
Any,
|
|
||||||
get_type_hints,
|
|
||||||
)
|
|
||||||
|
|
||||||
from tractor.discovery import (
|
|
||||||
_addr,
|
|
||||||
_multiaddr,
|
|
||||||
)
|
|
||||||
from tractor.ipc import (
|
|
||||||
_tcp,
|
|
||||||
_uds,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_cold_import(code: str) -> dict[str, object]:
|
def run_cold_import(code: str) -> dict[str, object]:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
|
|
@ -44,8 +30,8 @@ def test_lazy_to_asyncio_package_api():
|
||||||
Before the lazy conversion, package import side effects exposed
|
Before the lazy conversion, package import side effects exposed
|
||||||
`to_asyncio` to `dir()` and wildcard imports. Exercise those APIs
|
`to_asyncio` to `dir()` and wildcard imports. Exercise those APIs
|
||||||
in cold interpreters so this test proves normal `import tractor`
|
in cold interpreters so this test proves normal `import tractor`
|
||||||
leaves `asyncio` unloaded, while discovery and wildcard access
|
leaves `asyncio` unloaded, while introspection and wildcard
|
||||||
still advertise and resolve the public submodule.
|
access still advertise and resolve the public submodule.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
cold = run_cold_import(
|
cold = run_cold_import(
|
||||||
|
|
@ -102,6 +88,12 @@ def test_cold_import_budget():
|
||||||
'bidict',
|
'bidict',
|
||||||
'colorlog',
|
'colorlog',
|
||||||
'multiaddr',
|
'multiaddr',
|
||||||
|
'multibase',
|
||||||
|
'pyroute2',
|
||||||
|
'tractor.discovery._multiaddr',
|
||||||
|
'tractor.net',
|
||||||
|
'tractor.net._bindspace',
|
||||||
|
'tractor.net._tunnel',
|
||||||
'wrapt',
|
'wrapt',
|
||||||
)
|
)
|
||||||
code = (
|
code = (
|
||||||
|
|
@ -136,25 +128,173 @@ def test_cold_import_budget():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_lazy_annotation_names_resolve():
|
def test_lazy_net_package_api():
|
||||||
'''
|
'''
|
||||||
Resolve annotations without importing optional dependencies.
|
Keep the public network package cold until symbol access.
|
||||||
|
|
||||||
Moving annotation-only third-party names under `TYPE_CHECKING`
|
The old discovery re-exports imported bindspace, tunnel,
|
||||||
left their runtime globals undefined, causing
|
multiaddr and optional dependencies while initializing a package.
|
||||||
`typing.get_type_hints()` to raise `NameError`. Resolve every
|
Import `tractor.net` in a clean interpreter, inspect its public
|
||||||
affected API and prove the lazy aliases retain import-free runtime
|
surface, and prove no implementation or optional dependency was
|
||||||
introspection.
|
loaded. Then resolve one symbol from each backing module and
|
||||||
|
prove the facade caches each value while preserving boundaries.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
assert get_type_hints(_multiaddr.mk_maddr)['return'] is Any
|
modules: tuple[str, ...] = (
|
||||||
assert get_type_hints(_tcp.MsgpackTCPStream.maddr.fget)[
|
'tractor.net._bindspace',
|
||||||
'return'
|
'tractor.net._tunnel',
|
||||||
] is Any
|
'tractor.discovery._multiaddr',
|
||||||
assert get_type_hints(_uds.MsgpackUDSStream.maddr.fget)[
|
'multiaddr',
|
||||||
'return'
|
'multibase',
|
||||||
] == Any|str
|
'pyroute2',
|
||||||
assert get_type_hints(_addr.Address.get_random)[
|
)
|
||||||
'current_actor'
|
cold: dict[str, object] = run_cold_import(
|
||||||
] is Any
|
'import json, sys; import tractor.net as net; '
|
||||||
assert _addr.__annotations__['_address_types'].startswith('dict')
|
f'names = {modules!r}; '
|
||||||
|
'print(json.dumps({'
|
||||||
|
'"public": all(name in dir(net) for name in net.__all__), '
|
||||||
|
'"loaded": [name for name in names if name in sys.modules]'
|
||||||
|
'}))'
|
||||||
|
)
|
||||||
|
assert cold == {
|
||||||
|
'public': True,
|
||||||
|
'loaded': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved: dict[str, object] = run_cold_import(
|
||||||
|
'import json, sys; import tractor.net as net; '
|
||||||
|
'bindspace = net.BindspaceSpec; '
|
||||||
|
'bindspace_cached = net.BindspaceSpec is bindspace; '
|
||||||
|
'maddr = net.mk_maddr; '
|
||||||
|
'maddr_cached = net.mk_maddr is maddr; '
|
||||||
|
'tunnel = net.WGTunnelSpec; '
|
||||||
|
'tunnel_cached = net.WGTunnelSpec is tunnel; '
|
||||||
|
'print(json.dumps({'
|
||||||
|
'"bindspace_cached": bindspace_cached, '
|
||||||
|
'"maddr_cached": maddr_cached, '
|
||||||
|
'"tunnel_cached": tunnel_cached, '
|
||||||
|
'"bindspace_module": bindspace.__module__, '
|
||||||
|
'"maddr_module": maddr.__module__, '
|
||||||
|
'"tunnel_module": tunnel.__module__, '
|
||||||
|
'"multiaddr_loaded": "multiaddr" in sys.modules, '
|
||||||
|
'"pyroute2_loaded": "pyroute2" in sys.modules'
|
||||||
|
'}))'
|
||||||
|
)
|
||||||
|
assert resolved == {
|
||||||
|
'bindspace_cached': True,
|
||||||
|
'maddr_cached': True,
|
||||||
|
'tunnel_cached': True,
|
||||||
|
'bindspace_module': 'tractor.net._bindspace',
|
||||||
|
'maddr_module': 'tractor.discovery._multiaddr',
|
||||||
|
'tunnel_module': 'tractor.net._tunnel',
|
||||||
|
'multiaddr_loaded': False,
|
||||||
|
'pyroute2_loaded': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_net_root_export_and_old_discovery_surface():
|
||||||
|
'''
|
||||||
|
Publish networking only from its approved namespace.
|
||||||
|
|
||||||
|
Before extraction, unshipped network names and implementation
|
||||||
|
modules lived under `tractor.discovery`. Exercise root attribute
|
||||||
|
and wildcard access in clean interpreters, proving `tractor.net`
|
||||||
|
is discoverable and cached without loading implementations. Also
|
||||||
|
prove the old exports are absent and their modules no longer
|
||||||
|
resolve, preventing accidental compatibility aliases.
|
||||||
|
|
||||||
|
'''
|
||||||
|
root: dict[str, object] = run_cold_import(
|
||||||
|
'import json, sys, tractor; '
|
||||||
|
'advertised = "net" in dir(tractor); '
|
||||||
|
'net = tractor.net; '
|
||||||
|
'print(json.dumps({'
|
||||||
|
'"advertised": advertised, '
|
||||||
|
'"cached": tractor.net is net, '
|
||||||
|
'"module": net.__name__, '
|
||||||
|
'"bindspace_loaded": '
|
||||||
|
'"tractor.net._bindspace" in sys.modules, '
|
||||||
|
'"tunnel_loaded": "tractor.net._tunnel" in sys.modules'
|
||||||
|
'}))'
|
||||||
|
)
|
||||||
|
assert root == {
|
||||||
|
'advertised': True,
|
||||||
|
'cached': True,
|
||||||
|
'module': 'tractor.net',
|
||||||
|
'bindspace_loaded': False,
|
||||||
|
'tunnel_loaded': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
old: dict[str, object] = run_cold_import(
|
||||||
|
'import importlib.util, json; '
|
||||||
|
'import tractor.discovery as discovery; '
|
||||||
|
'old_names = ("Bindspace", "TunnelledAddress", '
|
||||||
|
'"mk_maddr", "parse_maddr", "parse_endpoints"); '
|
||||||
|
'old_modules = ("tractor.discovery._bindspace", '
|
||||||
|
'"tractor.discovery._tunnel"); '
|
||||||
|
'print(json.dumps({'
|
||||||
|
'"exports": [name for name in old_names '
|
||||||
|
'if hasattr(discovery, name)], '
|
||||||
|
'"modules": [name for name in old_modules '
|
||||||
|
'if importlib.util.find_spec(name) is not None]'
|
||||||
|
'}))'
|
||||||
|
)
|
||||||
|
assert old == {
|
||||||
|
'exports': [],
|
||||||
|
'modules': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_types_are_dependency_neutral():
|
||||||
|
'''
|
||||||
|
Keep canonical address types independent of runtime implementations.
|
||||||
|
|
||||||
|
Annotation-only imports previously rebound unavailable model names
|
||||||
|
to `Any` so `get_type_hints()` could resolve them. That test only
|
||||||
|
verified its own workaround and hid the `_addr`/transport import
|
||||||
|
cycle. Import the canonical declarations in a cold interpreter and
|
||||||
|
prove they do not pull in optional networking modules. Then prove
|
||||||
|
each implementation imports the same real declarations instead of
|
||||||
|
substituting `Any` under the model names.
|
||||||
|
|
||||||
|
'''
|
||||||
|
cold = run_cold_import(
|
||||||
|
'import json, sys; '
|
||||||
|
'from tractor.discovery import types; '
|
||||||
|
'blocked = ('
|
||||||
|
'"multiaddr", "multibase", "pyroute2", '
|
||||||
|
'"tractor.discovery._multiaddr", "tractor.net", '
|
||||||
|
'"tractor.net._bindspace", "tractor.net._tunnel"); '
|
||||||
|
'print(json.dumps({'
|
||||||
|
'"address_module": types.Address.__module__, '
|
||||||
|
'"blocked": [name for name in blocked '
|
||||||
|
'if name in sys.modules]}))'
|
||||||
|
)
|
||||||
|
assert cold == {
|
||||||
|
'address_module': 'tractor.discovery.types',
|
||||||
|
'blocked': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
from tractor.discovery import (
|
||||||
|
_addr,
|
||||||
|
_multiaddr,
|
||||||
|
types,
|
||||||
|
)
|
||||||
|
from tractor.ipc import (
|
||||||
|
_chan,
|
||||||
|
_server,
|
||||||
|
_tcp,
|
||||||
|
_transport,
|
||||||
|
_uds,
|
||||||
|
)
|
||||||
|
from tractor.net import _tunnel
|
||||||
|
|
||||||
|
assert _addr.Address is types.Address
|
||||||
|
assert _addr.UnwrappedAddress is types.UnwrappedAddress
|
||||||
|
assert _multiaddr.AddressDeclaration is types.AddressDeclaration
|
||||||
|
assert _tcp.TaggedTCPAddress is types.TaggedTCPAddress
|
||||||
|
assert _uds.TaggedUnixAddress is types.TaggedUnixAddress
|
||||||
|
assert _transport.Address is types.Address
|
||||||
|
assert _chan.AddressDeclaration is types.AddressDeclaration
|
||||||
|
assert _server.AddressDeclaration is types.AddressDeclaration
|
||||||
|
assert _tunnel.AddressDeclaration is types.AddressDeclaration
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,15 +10,25 @@ API design.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
)
|
)
|
||||||
|
from unittest.mock import (
|
||||||
|
AsyncMock,
|
||||||
|
MagicMock,
|
||||||
|
)
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
|
from tractor._exceptions import ActorFailure
|
||||||
from tractor._testing import tractor_test
|
from tractor._testing import tractor_test
|
||||||
|
from tractor.spawn import (
|
||||||
|
_spawn,
|
||||||
|
_trio,
|
||||||
|
)
|
||||||
|
|
||||||
data_to_pass_down = {
|
data_to_pass_down = {
|
||||||
'doggy': 10,
|
'doggy': 10,
|
||||||
|
|
@ -26,6 +36,396 @@ data_to_pass_down = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_peer_handshake_wins_child_boot_race() -> None:
|
||||||
|
'''
|
||||||
|
A connected child must cancel process-death monitoring cleanly.
|
||||||
|
|
||||||
|
Start the death waiter first and hold it at a checkpoint. Let the
|
||||||
|
fake server then return one peer event and channel. The helper must
|
||||||
|
preserve the normal handshake result and cancel the losing process
|
||||||
|
waiter before its nursery exits. The fake explicitly catches and
|
||||||
|
re-raises `trio.Cancelled` to prove cancellation caused its exit.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Control the handshake-first schedule with Trio events.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('handshake-child', 'test')
|
||||||
|
death_started = trio.Event()
|
||||||
|
death_cancelled = trio.Event()
|
||||||
|
peer_event = trio.Event()
|
||||||
|
channel = object()
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Return the peer only after death monitoring is active.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
await death_started.wait()
|
||||||
|
return (peer_event, channel)
|
||||||
|
|
||||||
|
async def wait_for_death() -> int:
|
||||||
|
'''
|
||||||
|
Block until the winning handshake cancels this waiter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
death_started.set()
|
||||||
|
try:
|
||||||
|
await trio.sleep_forever()
|
||||||
|
except trio.Cancelled:
|
||||||
|
death_cancelled.set()
|
||||||
|
raise
|
||||||
|
|
||||||
|
result = await _spawn.wait_for_peer_or_proc_death(
|
||||||
|
ipc_server=SimpleNamespace(
|
||||||
|
wait_for_peer=wait_for_peer,
|
||||||
|
),
|
||||||
|
uid=uid,
|
||||||
|
proc_wait=wait_for_death,
|
||||||
|
proc_repr='handshake-proc',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == (peer_event, channel)
|
||||||
|
assert death_cancelled.is_set()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_child_death_wins_peer_handshake_race() -> None:
|
||||||
|
'''
|
||||||
|
Pre-handshake child death must fail startup instead of hanging.
|
||||||
|
|
||||||
|
Start the peer waiter first and leave it parked like
|
||||||
|
`IPCServer.wait_for_peer()` on an unset event. Return a distinctive
|
||||||
|
process status from the competing waiter, then prove the helper
|
||||||
|
cancels the handshake and raises `ActorFailure` with child identity,
|
||||||
|
status, and process diagnostics.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Control the death-first schedule with Trio events.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('dead-child', 'test')
|
||||||
|
handshake_started = trio.Event()
|
||||||
|
handshake_cancelled = trio.Event()
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Park until process death cancels this handshake waiter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
handshake_started.set()
|
||||||
|
try:
|
||||||
|
await trio.sleep_forever()
|
||||||
|
except trio.Cancelled:
|
||||||
|
handshake_cancelled.set()
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def wait_for_death() -> int:
|
||||||
|
'''
|
||||||
|
Report child death after handshake monitoring is active.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await handshake_started.wait()
|
||||||
|
return 23
|
||||||
|
|
||||||
|
with pytest.raises(ActorFailure) as exc_info:
|
||||||
|
await _spawn.wait_for_peer_or_proc_death(
|
||||||
|
ipc_server=SimpleNamespace(
|
||||||
|
wait_for_peer=wait_for_peer,
|
||||||
|
),
|
||||||
|
uid=uid,
|
||||||
|
proc_wait=wait_for_death,
|
||||||
|
proc_repr='dead-proc',
|
||||||
|
)
|
||||||
|
|
||||||
|
message: str = str(exc_info.value)
|
||||||
|
assert repr(uid) in message
|
||||||
|
assert 'died during boot' in message
|
||||||
|
assert '(rc=23)' in message
|
||||||
|
assert 'parent-handshake' in message
|
||||||
|
assert 'dead-proc' in message
|
||||||
|
assert handshake_cancelled.is_set()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_child_death_wins_simultaneous_boot_results() -> None:
|
||||||
|
'''
|
||||||
|
Observed process death must outrank a simultaneous handshake.
|
||||||
|
|
||||||
|
Hold both fake waits behind one barrier with cancellation shielding,
|
||||||
|
then release them together so both publish a committed result before
|
||||||
|
sibling cancellation takes effect. Because the child has exited
|
||||||
|
before receiving its `SpawnSpec`, bootstrap must raise `ActorFailure`
|
||||||
|
rather than return its briefly established channel.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Release both boot outcomes from one controlled barrier.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('simultaneous-child', 'test')
|
||||||
|
handshake_ready = trio.Event()
|
||||||
|
death_ready = trio.Event()
|
||||||
|
release = trio.Event()
|
||||||
|
peer_event = trio.Event()
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Publish a handshake despite sibling cancellation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
handshake_ready.set()
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
await release.wait()
|
||||||
|
return (peer_event, object())
|
||||||
|
|
||||||
|
async def wait_for_death() -> int:
|
||||||
|
'''
|
||||||
|
Publish process death despite sibling cancellation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
death_ready.set()
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
await release.wait()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def release_both() -> None:
|
||||||
|
'''
|
||||||
|
Open the barrier only after both waiters are parked.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await handshake_ready.wait()
|
||||||
|
await death_ready.wait()
|
||||||
|
release.set()
|
||||||
|
|
||||||
|
async with trio.open_nursery() as nursery:
|
||||||
|
nursery.start_soon(release_both)
|
||||||
|
with pytest.raises(
|
||||||
|
ActorFailure,
|
||||||
|
match=r'simultaneous-child.*rc=0',
|
||||||
|
):
|
||||||
|
await _spawn.wait_for_peer_or_proc_death(
|
||||||
|
ipc_server=SimpleNamespace(
|
||||||
|
wait_for_peer=wait_for_peer,
|
||||||
|
),
|
||||||
|
uid=uid,
|
||||||
|
proc_wait=wait_for_death,
|
||||||
|
)
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('failing_waiter', ('handshake', 'death'))
|
||||||
|
def test_child_boot_race_preserves_waiter_error(
|
||||||
|
failing_waiter: str,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Waiter failures must retain their original exception identity.
|
||||||
|
|
||||||
|
Park the non-failing sibling and raise one unique error from either
|
||||||
|
the peer or process waiter. The helper's internal nursery must
|
||||||
|
cancel the sibling and re-raise that exact exception instead of
|
||||||
|
wrapping it in an `ExceptionGroup`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Trigger one selected waiter after its sibling starts.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('errored-child', 'test')
|
||||||
|
sibling_started = trio.Event()
|
||||||
|
wait_error = RuntimeError(f'{failing_waiter} failed')
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Raise or park according to the selected peer schedule.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
if failing_waiter == 'handshake':
|
||||||
|
await sibling_started.wait()
|
||||||
|
raise wait_error
|
||||||
|
|
||||||
|
sibling_started.set()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
async def wait_for_death() -> int:
|
||||||
|
'''
|
||||||
|
Raise or park according to the selected process schedule.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if failing_waiter == 'death':
|
||||||
|
await sibling_started.wait()
|
||||||
|
raise wait_error
|
||||||
|
|
||||||
|
sibling_started.set()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as exc_info:
|
||||||
|
await _spawn.wait_for_peer_or_proc_death(
|
||||||
|
ipc_server=SimpleNamespace(
|
||||||
|
wait_for_peer=wait_for_peer,
|
||||||
|
),
|
||||||
|
uid=uid,
|
||||||
|
proc_wait=wait_for_death,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value is wait_error
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trio_proc_cleans_failed_child_peer_event(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Death-first Trio startup must release provisional peer state.
|
||||||
|
|
||||||
|
Return one already-dead fake process while its server handshake
|
||||||
|
parks forever. The fake nursery proves the peer event exists before
|
||||||
|
provisional child publication. After `ActorFailure`, both that
|
||||||
|
exact event and the provisional child record must be gone so repeated
|
||||||
|
failed spawns cannot leak server state.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('dead-trio-child', 'test')
|
||||||
|
proc: trio.Process = MagicMock(spec=trio.Process)
|
||||||
|
proc.pid = 1234
|
||||||
|
proc.wait = AsyncMock(return_value=23)
|
||||||
|
proc.poll.return_value = 23
|
||||||
|
proc.__str__.return_value = 'dead-trio-proc'
|
||||||
|
|
||||||
|
class FakeServer:
|
||||||
|
'''
|
||||||
|
Hold the peer registry used during Trio child startup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._peer_connected: dict[
|
||||||
|
tuple[str, str],
|
||||||
|
trio.Event,
|
||||||
|
] = {}
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
self,
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Park like a child that never reaches its handshake.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
server = FakeServer()
|
||||||
|
|
||||||
|
class FakeNursery:
|
||||||
|
'''
|
||||||
|
Track provisional child publication and cleanup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._actor = SimpleNamespace(ipc_server=server)
|
||||||
|
self._children: dict[tuple[str, str], tuple] = {}
|
||||||
|
|
||||||
|
def _register_child(
|
||||||
|
self,
|
||||||
|
subactor: object,
|
||||||
|
proc: object,
|
||||||
|
portal: object|None,
|
||||||
|
) -> tuple[trio.Event, trio.Event, bool]:
|
||||||
|
'''
|
||||||
|
Require peer-event registration before child publication.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert uid in server._peer_connected
|
||||||
|
assert portal is None
|
||||||
|
self._children[uid] = (subactor, proc, portal)
|
||||||
|
return (trio.Event(), trio.Event(), False)
|
||||||
|
|
||||||
|
async def fake_open_process(
|
||||||
|
command: list[str],
|
||||||
|
**kwargs: object,
|
||||||
|
) -> trio.Process:
|
||||||
|
'''
|
||||||
|
Return a process whose death wins the bootstrap race.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert command
|
||||||
|
return proc
|
||||||
|
|
||||||
|
async def fake_wait_for_debugger(**kwargs: object) -> None:
|
||||||
|
'''
|
||||||
|
Keep hard-reap cleanup deterministic and non-interactive.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_trio.trio.lowlevel,
|
||||||
|
'open_process',
|
||||||
|
fake_open_process,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_trio.debug,
|
||||||
|
'maybe_wait_for_debugger',
|
||||||
|
fake_wait_for_debugger,
|
||||||
|
)
|
||||||
|
|
||||||
|
nursery = FakeNursery()
|
||||||
|
subactor = SimpleNamespace(
|
||||||
|
aid=tractor.msg.Aid(
|
||||||
|
name=uid[0],
|
||||||
|
uuid=uid[1],
|
||||||
|
),
|
||||||
|
loglevel=None,
|
||||||
|
pformat=lambda: 'dead-trio-child',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Run the full Trio backend through death-first cleanup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with pytest.raises(
|
||||||
|
ActorFailure,
|
||||||
|
match=r'dead-trio-child.*rc=23',
|
||||||
|
):
|
||||||
|
await _trio.trio_proc(
|
||||||
|
name=uid[0],
|
||||||
|
actor_nursery=nursery,
|
||||||
|
subactor=subactor,
|
||||||
|
errors={},
|
||||||
|
bind_addrs=[],
|
||||||
|
parent_addr=('127.0.0.1', 1616),
|
||||||
|
_runtime_vars={},
|
||||||
|
)
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
assert server._peer_connected == {}
|
||||||
|
assert nursery._children == {}
|
||||||
|
|
||||||
|
|
||||||
async def run_same_func_in_child(
|
async def run_same_func_in_child(
|
||||||
should_be_root: bool,
|
should_be_root: bool,
|
||||||
data: dict,
|
data: dict,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
tractor: structured concurrent ``trio``-"actors".
|
tractor: structured concurrent ``trio``-"actors".
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from types import ModuleType as _ModuleType
|
||||||
|
|
||||||
from ._clustering import (
|
from ._clustering import (
|
||||||
open_actor_cluster as open_actor_cluster,
|
open_actor_cluster as open_actor_cluster,
|
||||||
|
|
@ -82,6 +83,7 @@ __all__: tuple[str, ...] = tuple(
|
||||||
for name in globals()
|
for name in globals()
|
||||||
if not name.startswith('_')
|
if not name.startswith('_')
|
||||||
) + (
|
) + (
|
||||||
|
'net',
|
||||||
'to_asyncio',
|
'to_asyncio',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -92,21 +94,18 @@ def __dir__() -> list[str]:
|
||||||
|
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str):
|
||||||
'''
|
'''
|
||||||
PEP 562 lazy sub-module loading, presently only for
|
PEP 562 lazy public sub-package loading.
|
||||||
`.to_asyncio` which (transitively) imports `asyncio`
|
|
||||||
itself: a non-trivial multi-ms chunk of the eager
|
|
||||||
`import tractor` cost (gh #470) unneeded by
|
|
||||||
`trio`-only apps.
|
|
||||||
|
|
||||||
Any `tractor.to_asyncio.<attr>` access (or a
|
`tractor.to_asyncio` transitively imports `asyncio`, while
|
||||||
`from tractor import to_asyncio`) still works, the
|
`tractor.net` owns optional network dependencies. Neither is
|
||||||
sub-mod is simply imported on first-access instead
|
needed by most applications merely importing the root package.
|
||||||
of at pkg-import time.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if name == 'to_asyncio':
|
if name in ('net', 'to_asyncio'):
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
return import_module('.to_asyncio', __name__)
|
module: _ModuleType = import_module(f'.{name}', __name__)
|
||||||
|
globals()[name] = module
|
||||||
|
return module
|
||||||
|
|
||||||
raise AttributeError(
|
raise AttributeError(
|
||||||
f'module {__name__!r} has no attribute {name!r}'
|
f'module {__name__!r} has no attribute {name!r}'
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ from ast import literal_eval
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .runtime._runtime import Actor
|
from .runtime._runtime import Actor
|
||||||
from .spawn._entry import _trio_main
|
from .spawn._entry import (
|
||||||
|
_consume_netns_bootstrap,
|
||||||
|
_trio_main,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .discovery._addr import UnwrappedAddress
|
from .discovery._addr import UnwrappedAddress
|
||||||
|
|
@ -46,12 +49,41 @@ def parse_ipaddr(arg):
|
||||||
return arg
|
return arg
|
||||||
|
|
||||||
|
|
||||||
|
def parse_netns_bootstrap(arg: str) -> tuple[int, int, int]:
|
||||||
|
'''
|
||||||
|
Parse one atomic inherited namespace capability.
|
||||||
|
|
||||||
|
Descriptor and device/inode validation remains in
|
||||||
|
`_consume_netns_bootstrap()` so every valid descriptor-shaped
|
||||||
|
input reaches its exact child-owned cleanup boundary.
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
value: object = literal_eval(arg)
|
||||||
|
except (ValueError, SyntaxError) as exc:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
'netns bootstrap must be an `(fd, device, inode)` tuple'
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if (
|
||||||
|
not isinstance(value, tuple)
|
||||||
|
or
|
||||||
|
len(value) != 3
|
||||||
|
):
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
'netns bootstrap must be an `(fd, device, inode)` tuple'
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _actor_child_main(
|
def _actor_child_main(
|
||||||
uid: tuple[str, str],
|
uid: tuple[str, str],
|
||||||
loglevel: str | None,
|
loglevel: str | None,
|
||||||
parent_addr: UnwrappedAddress | None,
|
parent_addr: UnwrappedAddress | None,
|
||||||
infect_asyncio: bool,
|
infect_asyncio: bool,
|
||||||
spawn_method: SpawnMethodKey = 'trio',
|
spawn_method: SpawnMethodKey = 'trio',
|
||||||
|
netns_bootstrap: tuple[int, int, int]|None = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
@ -62,7 +94,13 @@ def _actor_child_main(
|
||||||
invokes this from inside a fresh `concurrent.interpreters`
|
invokes this from inside a fresh `concurrent.interpreters`
|
||||||
sub-interpreter via `Interpreter.call()`.
|
sub-interpreter via `Interpreter.call()`.
|
||||||
|
|
||||||
|
Consume `netns_bootstrap` before Trio patching, actor construction,
|
||||||
|
process-title setup, or actor-runtime entry. The spawn backend must
|
||||||
|
supply an exclusively child-owned FD duplicate.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
_consume_netns_bootstrap(netns_bootstrap)
|
||||||
|
|
||||||
# Apply defensive monkey-patches for upstream `trio`
|
# Apply defensive monkey-patches for upstream `trio`
|
||||||
# bugs we've encountered while running tractor — see
|
# bugs we've encountered while running tractor — see
|
||||||
# `tractor.trionics.patches` for the catalog +
|
# `tractor.trionics.patches` for the catalog +
|
||||||
|
|
@ -113,7 +151,11 @@ def _actor_child_main(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def main(argv: list[str]|None = None) -> None:
|
||||||
|
'''
|
||||||
|
Parse Trio child-bootstrap arguments and enter actor runtime.
|
||||||
|
|
||||||
|
'''
|
||||||
__tracebackhide__: bool = True
|
__tracebackhide__: bool = True
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
|
|
@ -121,7 +163,11 @@ if __name__ == "__main__":
|
||||||
parser.add_argument("--loglevel", type=str)
|
parser.add_argument("--loglevel", type=str)
|
||||||
parser.add_argument("--parent_addr", type=parse_ipaddr)
|
parser.add_argument("--parent_addr", type=parse_ipaddr)
|
||||||
parser.add_argument("--asyncio", action='store_true')
|
parser.add_argument("--asyncio", action='store_true')
|
||||||
args = parser.parse_args()
|
parser.add_argument(
|
||||||
|
'--netns_bootstrap',
|
||||||
|
type=parse_netns_bootstrap,
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
_actor_child_main(
|
_actor_child_main(
|
||||||
uid=args.uid,
|
uid=args.uid,
|
||||||
|
|
@ -129,4 +175,9 @@ if __name__ == "__main__":
|
||||||
parent_addr=args.parent_addr,
|
parent_addr=args.parent_addr,
|
||||||
infect_asyncio=args.asyncio,
|
infect_asyncio=args.asyncio,
|
||||||
spawn_method='trio',
|
spawn_method='trio',
|
||||||
|
netns_bootstrap=args.netns_bootstrap,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@
|
||||||
Root actor runtime ignition(s).
|
Root actor runtime ignition(s).
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import (
|
from contextlib import (
|
||||||
asynccontextmanager as acm,
|
asynccontextmanager as acm,
|
||||||
)
|
)
|
||||||
|
|
@ -49,13 +51,15 @@ from .runtime import _state
|
||||||
from . import log
|
from . import log
|
||||||
from .discovery._api import _probe_registry_addrs
|
from .discovery._api import _probe_registry_addrs
|
||||||
from .discovery._addr import (
|
from .discovery._addr import (
|
||||||
Address,
|
|
||||||
UnwrappedAddress,
|
|
||||||
default_lo_addrs,
|
default_lo_addrs,
|
||||||
|
get_address_cls,
|
||||||
mk_uuid,
|
mk_uuid,
|
||||||
wrap_address,
|
wrap_address,
|
||||||
)
|
)
|
||||||
from .discovery._tunnel import strip_tunnels
|
from .discovery.types import (
|
||||||
|
Address,
|
||||||
|
UnwrappedAddress,
|
||||||
|
)
|
||||||
from .trionics import (
|
from .trionics import (
|
||||||
is_multi_cancelled,
|
is_multi_cancelled,
|
||||||
collapse_eg,
|
collapse_eg,
|
||||||
|
|
@ -157,8 +161,13 @@ async def maybe_block_bp(
|
||||||
@acm
|
@acm
|
||||||
async def open_root_actor(
|
async def open_root_actor(
|
||||||
*,
|
*,
|
||||||
|
# Low-level realized scope. A future tunnelled-address bootstrap
|
||||||
|
# may open and supply this capability internally.
|
||||||
|
# Root netns entry is deferred to a pre-Trio launcher; the async
|
||||||
|
# root API cannot safely switch the process networking scope.
|
||||||
|
|
||||||
tpt_bind_addrs: list[
|
tpt_bind_addrs: list[
|
||||||
Address # `Address.get_random()` case
|
Address # concrete transport address case
|
||||||
|UnwrappedAddress # registrar case `= uw_reg_addrs`
|
|UnwrappedAddress # registrar case `= uw_reg_addrs`
|
||||||
]|None = None,
|
]|None = None,
|
||||||
|
|
||||||
|
|
@ -220,6 +229,10 @@ async def open_root_actor(
|
||||||
All (disjoint) actor-process-trees-as-programs are created via
|
All (disjoint) actor-process-trees-as-programs are created via
|
||||||
this entrypoint.
|
this entrypoint.
|
||||||
|
|
||||||
|
To supervise a network-scoped child, open its bindspace outside
|
||||||
|
an ActorNursery and pass it to `start_actor()`. Root namespace
|
||||||
|
entry must happen before `trio.run()` and worker-thread creation.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# XXX NEVER allow nested actor-trees!
|
# XXX NEVER allow nested actor-trees!
|
||||||
if already_actor := _state.current_actor(
|
if already_actor := _state.current_actor(
|
||||||
|
|
@ -241,9 +254,11 @@ async def open_root_actor(
|
||||||
)
|
)
|
||||||
|
|
||||||
# debug.mk_pdb().set_trace()
|
# debug.mk_pdb().set_trace()
|
||||||
async with maybe_block_bp(
|
async with (
|
||||||
debug_mode=debug_mode,
|
maybe_block_bp(
|
||||||
maybe_enable_greenback=maybe_enable_greenback,
|
debug_mode=debug_mode,
|
||||||
|
maybe_enable_greenback=maybe_enable_greenback,
|
||||||
|
),
|
||||||
):
|
):
|
||||||
if enable_transports is None:
|
if enable_transports is None:
|
||||||
enable_transports: list[str] = _state.current_ipc_protos()
|
enable_transports: list[str] = _state.current_ipc_protos()
|
||||||
|
|
@ -505,8 +520,13 @@ async def open_root_actor(
|
||||||
# XXX INSTEAD, bind random addrs using the same tpt
|
# XXX INSTEAD, bind random addrs using the same tpt
|
||||||
# proto if not already provided.
|
# proto if not already provided.
|
||||||
if not tpt_bind_addrs:
|
if not tpt_bind_addrs:
|
||||||
|
from .net._tunnel import strip_tunnels
|
||||||
|
|
||||||
for addr in ponged_addrs:
|
for addr in ponged_addrs:
|
||||||
bindable_addr: Address = strip_tunnels(addr)
|
bindable_addr: Address = strip_tunnels(addr)
|
||||||
|
address_type = get_address_cls(
|
||||||
|
bindable_addr.proto_key
|
||||||
|
)
|
||||||
tpt_bind_addrs.append(
|
tpt_bind_addrs.append(
|
||||||
# XXX, these are `Address` NOT `UnwrappedAddress`.
|
# XXX, these are `Address` NOT `UnwrappedAddress`.
|
||||||
#
|
#
|
||||||
|
|
@ -514,7 +534,7 @@ async def open_root_actor(
|
||||||
# protos we allocate port=0 such that the system
|
# protos we allocate port=0 such that the system
|
||||||
# allocates a random value at bind time; this
|
# allocates a random value at bind time; this
|
||||||
# happens in the `.ipc.*` stack's backend.
|
# happens in the `.ipc.*` stack's backend.
|
||||||
bindable_addr.get_random(
|
address_type.get_random(
|
||||||
bindspace=bindable_addr.bindspace,
|
bindspace=bindable_addr.bindspace,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,48 +15,8 @@
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Discovery (protocols) API for automatic addressing
|
Actor discovery and registrar implementation package.
|
||||||
and location management of (service) actors.
|
|
||||||
|
|
||||||
NOTE: this ``__init__`` only eagerly imports the lightweight
|
Network declarations and helpers are public from `tractor.net`.
|
||||||
``._multiaddr`` and ``._tunnel`` submodules for public re-exports.
|
|
||||||
Heavier submodules like ``._addr`` and ``._api`` are NOT imported
|
|
||||||
here to avoid circular imports; use direct module paths for those.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from ._bindspace import (
|
|
||||||
Bindspace as Bindspace,
|
|
||||||
BindspaceKind as BindspaceKind,
|
|
||||||
BindspaceLifecycle as BindspaceLifecycle,
|
|
||||||
BindspaceOwnership as BindspaceOwnership,
|
|
||||||
BindspaceRef as BindspaceRef,
|
|
||||||
BindspaceSpec as BindspaceSpec,
|
|
||||||
CURRENT_NETNS as CURRENT_NETNS,
|
|
||||||
attach_netns as attach_netns,
|
|
||||||
open_bindspace as open_bindspace,
|
|
||||||
open_netns as open_netns,
|
|
||||||
)
|
|
||||||
from ._multiaddr import (
|
|
||||||
parse_endpoints as parse_endpoints,
|
|
||||||
parse_maddr as parse_maddr,
|
|
||||||
mk_maddr as mk_maddr,
|
|
||||||
)
|
|
||||||
from ._tunnel import (
|
|
||||||
TunnelledAddress as TunnelledAddress,
|
|
||||||
TunnelSpec as TunnelSpec,
|
|
||||||
WGTunnelSpec as WGTunnelSpec,
|
|
||||||
WGInterfaceConfig as WGInterfaceConfig,
|
|
||||||
WGPeerConfig as WGPeerConfig,
|
|
||||||
WGRole as WGRole,
|
|
||||||
mb_pubkey as mb_pubkey,
|
|
||||||
mk_wg_maddr as mk_wg_maddr,
|
|
||||||
open_wg_bindspace as open_wg_bindspace,
|
|
||||||
open_wg_iface as open_wg_iface,
|
|
||||||
parse_wg_maddr as parse_wg_maddr,
|
|
||||||
read_wg_peers as read_wg_peers,
|
|
||||||
read_wg_pubkey as read_wg_pubkey,
|
|
||||||
strip_tunnels as strip_tunnels,
|
|
||||||
tunnels_of as tunnels_of,
|
|
||||||
verify_wg_peer as verify_wg_peer,
|
|
||||||
wg8_pubkey as wg8_pubkey,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -15,19 +15,7 @@
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from typing import (
|
from typing import TypeAlias
|
||||||
Any,
|
|
||||||
Protocol,
|
|
||||||
ClassVar,
|
|
||||||
Literal,
|
|
||||||
Type,
|
|
||||||
TypeAlias,
|
|
||||||
TYPE_CHECKING,
|
|
||||||
)
|
|
||||||
|
|
||||||
from trio import (
|
|
||||||
SocketListener,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ..runtime._state import (
|
from ..runtime._state import (
|
||||||
|
|
@ -38,70 +26,19 @@ from ..ipc._uds import (
|
||||||
UDSAddress,
|
UDSAddress,
|
||||||
HAS_UDS,
|
HAS_UDS,
|
||||||
)
|
)
|
||||||
|
from .types import (
|
||||||
if TYPE_CHECKING:
|
Address as Address,
|
||||||
# ONLY type-annots, the eager import costs ~4.5ms
|
AddressDeclaration,
|
||||||
# of `import tractor` wall-time (gh #470).
|
LegacyUnwrappedAddress,
|
||||||
from ._tunnel import (
|
TaggedAddress,
|
||||||
TunnelledAddress,
|
TaggedUDSAlias,
|
||||||
)
|
UnwrappedAddress,
|
||||||
from ..runtime._runtime import Actor
|
)
|
||||||
else:
|
|
||||||
Actor = Any
|
|
||||||
TunnelledAddress = Any
|
|
||||||
|
|
||||||
log = get_logger()
|
log = get_logger()
|
||||||
|
|
||||||
# TODO, maybe breakout the netns key to a struct?
|
_AddressType: TypeAlias = type[TCPAddress]|type[UDSAddress]
|
||||||
# class NetNs(Struct)[str, int]:
|
|
||||||
# ...
|
|
||||||
|
|
||||||
# TODO, can't we just use a type alias
|
|
||||||
# for this? namely just some `tuple[str, int, str, str]`?
|
|
||||||
#
|
|
||||||
# -[ ] would also just be simpler to keep this as SockAddr[tuple]
|
|
||||||
# or something, implying it's just a simple pair of values which can
|
|
||||||
# presumably be mapped to all transports?
|
|
||||||
# -[ ] `pydoc socket.socket.getsockname()` delivers a 4-tuple for
|
|
||||||
# ipv6 `(hostaddr, port, flowinfo, scope_id)`.. so how should we
|
|
||||||
# handle that?
|
|
||||||
# -[ ] as a further alternative to this wrap()/unwrap() approach we
|
|
||||||
# could just implement `enc/dec_hook()`s for the `Address`-types
|
|
||||||
# and just deal with our internal objs directly and always and
|
|
||||||
# leave it to the codec layer to figure out marshalling?
|
|
||||||
# |_ would mean only one spot to do the `.unwrap()` (which we may
|
|
||||||
# end up needing to call from the hook()s anyway?)
|
|
||||||
# -[x] rename to `UnwrappedAddress[Descriptor]` ??
|
|
||||||
# seems like the right name as per,
|
|
||||||
# https://www.geeksforgeeks.org/introduction-to-address-descriptor/
|
|
||||||
#
|
|
||||||
TaggedTCPAddress: TypeAlias = tuple[
|
|
||||||
Literal['tcp'],
|
|
||||||
str,
|
|
||||||
int,
|
|
||||||
]
|
|
||||||
TaggedUnixAddress: TypeAlias = tuple[
|
|
||||||
Literal['unix'],
|
|
||||||
str,
|
|
||||||
]
|
|
||||||
TaggedUDSAlias: TypeAlias = tuple[
|
|
||||||
Literal['uds'],
|
|
||||||
str,
|
|
||||||
]
|
|
||||||
TaggedAddress: TypeAlias = (
|
|
||||||
TaggedTCPAddress
|
|
||||||
|TaggedUnixAddress
|
|
||||||
)
|
|
||||||
|
|
||||||
# Input-only compatibility forms retained for older callers and
|
|
||||||
# serialized payloads.
|
|
||||||
LegacyTCPAddress: TypeAlias = tuple[str, int]
|
|
||||||
LegacyUDSAddress: TypeAlias = tuple[str, str]
|
|
||||||
LegacyUnwrappedAddress: TypeAlias = (
|
|
||||||
LegacyTCPAddress
|
|
||||||
|LegacyUDSAddress
|
|
||||||
)
|
|
||||||
UnwrappedAddress = TaggedAddress
|
|
||||||
# ?TODO? should we also include another 2 fields from our `Aid` msg
|
# ?TODO? should we also include another 2 fields from our `Aid` msg
|
||||||
# such that we include the runtime `Actor.uid` of `.name` and `.uuid`?
|
# such that we include the runtime `Actor.uid` of `.name` and `.uuid`?
|
||||||
# - would ensure uniqueness across entire net?
|
# - would ensure uniqueness across entire net?
|
||||||
|
|
@ -109,106 +46,14 @@ UnwrappedAddress = TaggedAddress
|
||||||
# name"
|
# name"
|
||||||
|
|
||||||
|
|
||||||
# TODO, maybe rename to `SocketAddress`?
|
|
||||||
class Address(Protocol):
|
|
||||||
proto_key: ClassVar[str]
|
|
||||||
unwrapped_type: ClassVar[type]
|
|
||||||
|
|
||||||
# TODO, i feel like an `.is_bound()` is a better thing to
|
|
||||||
# support?
|
|
||||||
# Lke, what use does this have besides a noop and if it's not
|
|
||||||
# valid why aren't we erroring on creation/use?
|
|
||||||
@property
|
|
||||||
def is_valid(self) -> bool:
|
|
||||||
...
|
|
||||||
|
|
||||||
# TODO, maybe `.netns` is a better name?
|
|
||||||
@property
|
|
||||||
def namespace(self) -> tuple[str, str|int]|None:
|
|
||||||
'''
|
|
||||||
The if-available, OS-specific "network namespace" key.
|
|
||||||
|
|
||||||
'''
|
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bindspace(self) -> str:
|
|
||||||
'''
|
|
||||||
Deliver the socket address' "bindable space" from
|
|
||||||
a `socket.socket.bind()` and thus from the perspective of
|
|
||||||
specific transport protocol domain.
|
|
||||||
|
|
||||||
I.e. for most (layer-4) network-socket protocols this is
|
|
||||||
normally the ipv4/6 address, for UDS this is normally
|
|
||||||
a filesystem (sub-directory).
|
|
||||||
|
|
||||||
For (distributed) network protocols this is normally the routing
|
|
||||||
layer's domain/(ip-)address, though it might also include a "network namespace"
|
|
||||||
key different then the default.
|
|
||||||
|
|
||||||
For local-host-only transports this is either an explicit
|
|
||||||
namespace (with types defined by the OS: netns, Cgroup, IPC,
|
|
||||||
pid, etc. on linux) or failing that the sub-directory in the
|
|
||||||
filesys in which socket/shm files are located *under*.
|
|
||||||
|
|
||||||
'''
|
|
||||||
...
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_addr(cls, addr: UnwrappedAddress) -> Address:
|
|
||||||
...
|
|
||||||
|
|
||||||
def unwrap(self) -> UnwrappedAddress:
|
|
||||||
'''
|
|
||||||
Deliver the underying minimum field set in
|
|
||||||
a primitive python data type-structure.
|
|
||||||
'''
|
|
||||||
...
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_random(
|
|
||||||
cls,
|
|
||||||
current_actor: Actor,
|
|
||||||
bindspace: str|None = None,
|
|
||||||
) -> Address:
|
|
||||||
...
|
|
||||||
|
|
||||||
# TODO, this should be something like a `.get_def_registar_addr()`
|
|
||||||
# or similar since,
|
|
||||||
# - it should be a **host singleton** (not root/tree singleton)
|
|
||||||
# - we **only need this value** when one isn't provided to the
|
|
||||||
# runtime at boot and we want to implicitly provide a host-wide
|
|
||||||
# registrar.
|
|
||||||
# - each rooted-actor-tree should likely have its own
|
|
||||||
# micro-registry (likely the root being it), also see
|
|
||||||
@classmethod
|
|
||||||
def get_root(cls) -> Address:
|
|
||||||
...
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
...
|
|
||||||
|
|
||||||
def __eq__(self, other) -> bool:
|
|
||||||
...
|
|
||||||
|
|
||||||
async def open_listener(
|
|
||||||
self,
|
|
||||||
**kwargs,
|
|
||||||
) -> SocketListener:
|
|
||||||
...
|
|
||||||
|
|
||||||
async def close_listener(self):
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
# the address types available on this host: TCP always, UDS only
|
# the address types available on this host: TCP always, UDS only
|
||||||
# where usable (`HAS_UDS`). Both registries derive from this single
|
# where usable (`HAS_UDS`). Both registries derive from this single
|
||||||
# list via each type's `proto_key`.
|
# list via each type's `proto_key`.
|
||||||
_address_protos: list[Type[Address]] = [TCPAddress]
|
_address_protos: list[_AddressType] = [TCPAddress]
|
||||||
if HAS_UDS:
|
if HAS_UDS:
|
||||||
_address_protos.append(UDSAddress)
|
_address_protos.append(UDSAddress)
|
||||||
|
|
||||||
_address_types: dict[str, Type[Address]] = {
|
_address_types: dict[str, _AddressType] = {
|
||||||
cls.proto_key: cls
|
cls.proto_key: cls
|
||||||
for cls in _address_protos
|
for cls in _address_protos
|
||||||
}
|
}
|
||||||
|
|
@ -216,13 +61,22 @@ _address_types: dict[str, Type[Address]] = {
|
||||||
|
|
||||||
# TODO! really these are discovery sys default addrs ONLY useful for
|
# TODO! really these are discovery sys default addrs ONLY useful for
|
||||||
# when none is provided to a root actor on first boot.
|
# when none is provided to a root actor on first boot.
|
||||||
|
#
|
||||||
|
# TODO, this should be something like a `.get_def_registar_addr()`
|
||||||
|
# or similar since,
|
||||||
|
# - it should be a **host singleton** (not root/tree singleton)
|
||||||
|
# - we **only need this value** when one isn't provided to the
|
||||||
|
# runtime at boot and we want to implicitly provide a host-wide
|
||||||
|
# registrar.
|
||||||
|
# - each rooted-actor-tree should likely have its own
|
||||||
|
# micro-registry (likely the root being it), also see
|
||||||
_default_lo_addrs: dict[str, UnwrappedAddress] = {
|
_default_lo_addrs: dict[str, UnwrappedAddress] = {
|
||||||
cls.proto_key: cls.get_root().unwrap()
|
cls.proto_key: cls.get_root().unwrap()
|
||||||
for cls in _address_protos
|
for cls in _address_protos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_address_cls(name: str) -> Type[Address]:
|
def get_address_cls(name: str) -> _AddressType:
|
||||||
try:
|
try:
|
||||||
return _address_types[name]
|
return _address_types[name]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|
@ -233,12 +87,13 @@ def get_address_cls(name: str) -> Type[Address]:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_wrapped_addr(addr: any) -> bool:
|
def is_wrapped_addr(addr: object) -> bool:
|
||||||
# XXX NOTE, a `TunnelledAddress` is genuinely "wrapped" but is
|
# XXX NOTE, a `TunnelledAddress` is genuinely "wrapped" but is
|
||||||
# deliberately NOT in `_address_types`: it has no
|
# deliberately NOT in `_address_types`: it has no
|
||||||
# `MsgTransport` of its own (a tunnel is transparent to
|
# `MsgTransport` of its own (a tunnel is transparent to
|
||||||
# `socket(2)`), so it gets no proto-key entry. See `._tunnel`.
|
# `socket(2)`), so it gets no proto-key entry. See
|
||||||
from ._tunnel import TunnelledAddress
|
# `tractor.net._tunnel`.
|
||||||
|
from tractor.net._tunnel import TunnelledAddress
|
||||||
return (
|
return (
|
||||||
type(addr) in _address_types.values()
|
type(addr) in _address_types.values()
|
||||||
or
|
or
|
||||||
|
|
@ -263,10 +118,9 @@ def wrap_address(
|
||||||
|LegacyUnwrappedAddress
|
|LegacyUnwrappedAddress
|
||||||
|list[str|int]
|
|list[str|int]
|
||||||
|str
|
|str
|
||||||
|Address
|
|AddressDeclaration
|
||||||
|TunnelledAddress
|
|
||||||
),
|
),
|
||||||
) -> Address|TunnelledAddress:
|
) -> AddressDeclaration:
|
||||||
'''
|
'''
|
||||||
Wrap an `UnwrappedAddress` as an `Address`-type based
|
Wrap an `UnwrappedAddress` as an `Address`-type based
|
||||||
on matching builtin python data-structures which we adhoc
|
on matching builtin python data-structures which we adhoc
|
||||||
|
|
@ -283,7 +137,7 @@ def wrap_address(
|
||||||
if is_wrapped_addr(addr):
|
if is_wrapped_addr(addr):
|
||||||
return addr
|
return addr
|
||||||
|
|
||||||
cls: Type|None = None
|
cls: _AddressType|None = None
|
||||||
# if 'sock' in addr[0]:
|
# if 'sock' in addr[0]:
|
||||||
# import pdbp; pdbp.set_trace()
|
# import pdbp; pdbp.set_trace()
|
||||||
match addr:
|
match addr:
|
||||||
|
|
@ -327,13 +181,13 @@ def wrap_address(
|
||||||
|
|
|
|
||||||
[None, None]
|
[None, None]
|
||||||
):
|
):
|
||||||
cls: Type[Address] = get_address_cls(_def_tpt_proto)
|
cls = get_address_cls(_def_tpt_proto)
|
||||||
addr: UnwrappedAddress = cls.get_root().unwrap()
|
addr: UnwrappedAddress = cls.get_root().unwrap()
|
||||||
|
|
||||||
# multiaddr-format string, e.g.
|
# multiaddr-format string, e.g.
|
||||||
# '/ip4/127.0.0.1/tcp/1616'
|
# '/ip4/127.0.0.1/tcp/1616'
|
||||||
case str() if addr.startswith('/'):
|
case str() if addr.startswith('/'):
|
||||||
from tractor.discovery._multiaddr import (
|
from tractor.net import (
|
||||||
parse_maddr,
|
parse_maddr,
|
||||||
)
|
)
|
||||||
return parse_maddr(addr)
|
return parse_maddr(addr)
|
||||||
|
|
@ -353,7 +207,7 @@ def wrap_address(
|
||||||
|
|
||||||
def default_lo_addrs(
|
def default_lo_addrs(
|
||||||
transports: list[str],
|
transports: list[str],
|
||||||
) -> list[Type[Address]]:
|
) -> list[UnwrappedAddress]:
|
||||||
'''
|
'''
|
||||||
Return the default, host-singleton, registry address
|
Return the default, host-singleton, registry address
|
||||||
for an input transport key set.
|
for an input transport key set.
|
||||||
|
|
|
||||||
|
|
@ -27,24 +27,15 @@ Multiaddress support using the upstream `py-multiaddr` lib
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import ipaddress
|
import ipaddress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import (
|
from typing import TYPE_CHECKING
|
||||||
Any,
|
|
||||||
TYPE_CHECKING,
|
from .types import AddressDeclaration
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# NOTE, `multiaddr` is lazy-imported at first use
|
# NOTE, `multiaddr` is lazy-imported at first use
|
||||||
# (in the fns below) to keep it off the eager
|
# (in the fns below) to keep it off the eager
|
||||||
# `import tractor` path (gh #470).
|
# `import tractor` path (gh #470).
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
from tractor.discovery._addr import Address
|
|
||||||
from tractor.discovery._tunnel import (
|
|
||||||
TunnelledAddress,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
Multiaddr = Any
|
|
||||||
Address = Any
|
|
||||||
TunnelledAddress = Any
|
|
||||||
|
|
||||||
# map from tractor-internal `proto_key` identifiers
|
# map from tractor-internal `proto_key` identifiers
|
||||||
# to the standard multiaddr protocol name strings.
|
# to the standard multiaddr protocol name strings.
|
||||||
|
|
@ -61,7 +52,7 @@ _maddr_to_tpt_proto: dict[str, str] = {
|
||||||
|
|
||||||
|
|
||||||
def mk_maddr(
|
def mk_maddr(
|
||||||
addr: 'Address|TunnelledAddress',
|
addr: AddressDeclaration,
|
||||||
) -> Multiaddr:
|
) -> Multiaddr:
|
||||||
'''
|
'''
|
||||||
Construct a `Multiaddr` from a tractor `Address` instance,
|
Construct a `Multiaddr` from a tractor `Address` instance,
|
||||||
|
|
@ -71,7 +62,7 @@ def mk_maddr(
|
||||||
'''
|
'''
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
|
|
||||||
from ._tunnel import (
|
from tractor.net._tunnel import (
|
||||||
TunnelledAddress,
|
TunnelledAddress,
|
||||||
mk_wg_maddr,
|
mk_wg_maddr,
|
||||||
)
|
)
|
||||||
|
|
@ -111,7 +102,7 @@ def mk_maddr(
|
||||||
|
|
||||||
def parse_maddr(
|
def parse_maddr(
|
||||||
maddr_str: str,
|
maddr_str: str,
|
||||||
) -> 'Address|TunnelledAddress':
|
) -> AddressDeclaration:
|
||||||
'''
|
'''
|
||||||
Parse a multiaddr string into a tractor `Address`.
|
Parse a multiaddr string into a tractor `Address`.
|
||||||
|
|
||||||
|
|
@ -130,7 +121,7 @@ def parse_maddr(
|
||||||
# fails. Pre-checking the raw string would misclassify valid
|
# fails. Pre-checking the raw string would misclassify valid
|
||||||
# values such as `/unix/tmp/wg/service.sock`.
|
# values such as `/unix/tmp/wg/service.sock`.
|
||||||
if '/wg/' in maddr_str:
|
if '/wg/' in maddr_str:
|
||||||
from ._tunnel import _wg_proto_code
|
from tractor.net._tunnel import _wg_proto_code
|
||||||
_wg_proto_code()
|
_wg_proto_code()
|
||||||
raise
|
raise
|
||||||
proto_names: list[str] = [
|
proto_names: list[str] = [
|
||||||
|
|
@ -156,7 +147,7 @@ def parse_maddr(
|
||||||
)
|
)
|
||||||
|
|
||||||
case _ if 'wg' in proto_names:
|
case _ if 'wg' in proto_names:
|
||||||
from ._tunnel import parse_wg_maddr
|
from tractor.net._tunnel import parse_wg_maddr
|
||||||
return parse_wg_maddr(maddr)
|
return parse_wg_maddr(maddr)
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
|
|
@ -173,15 +164,15 @@ def parse_maddr(
|
||||||
# or raw unwrapped-address tuples (as accepted by
|
# or raw unwrapped-address tuples (as accepted by
|
||||||
# `wrap_address()`).
|
# `wrap_address()`).
|
||||||
EndpointsTable = dict[
|
EndpointsTable = dict[
|
||||||
str, # actor/service name
|
str, # actor/service name
|
||||||
list[str|tuple], # maddr strs or UnwrappedAddress
|
list[str|tuple|AddressDeclaration],
|
||||||
]
|
]
|
||||||
|
|
||||||
# output table: actor/service name -> list of wrapped address
|
# output table: actor/service name -> list of wrapped address
|
||||||
# declarations ready for bindspace handling.
|
# declarations ready for bindspace handling.
|
||||||
ParsedEndpoints = dict[
|
ParsedEndpoints = dict[
|
||||||
str, # actor/service name
|
str, # actor/service name
|
||||||
list['Address|TunnelledAddress'],
|
list[AddressDeclaration],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or
|
||||||
|
# modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public
|
||||||
|
# License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
'''
|
||||||
|
Dependency-neutral address typing declarations.
|
||||||
|
|
||||||
|
Keep this module independent of concrete IPC transports, actor
|
||||||
|
runtime models, network resources, tunnels, and optional
|
||||||
|
dependencies.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import (
|
||||||
|
ClassVar,
|
||||||
|
Literal,
|
||||||
|
Protocol,
|
||||||
|
TypeAlias,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO, maybe breakout the netns key to a struct?
|
||||||
|
# class NetNs(Struct)[str, int]:
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# TODO, can't we just use a type alias
|
||||||
|
# for this? namely just some `tuple[str, int, str, str]`?
|
||||||
|
#
|
||||||
|
# -[ ] would also just be simpler to keep this as
|
||||||
|
# SockAddr[tuple] or something, implying it's just a simple pair
|
||||||
|
# of values which can presumably be mapped to all transports?
|
||||||
|
# -[ ] `pydoc socket.socket.getsockname()` delivers a 4-tuple for
|
||||||
|
# ipv6 `(hostaddr, port, flowinfo, scope_id)`.. so how should we
|
||||||
|
# handle that?
|
||||||
|
# -[ ] as a further alternative to this wrap()/unwrap() approach we
|
||||||
|
# could just implement `enc/dec_hook()`s for the `Address`-types
|
||||||
|
# and just deal with our internal objs directly and always and
|
||||||
|
# leave it to the codec layer to figure out marshalling?
|
||||||
|
# |_ would mean only one spot to do the `.unwrap()` (which we may
|
||||||
|
# end up needing to call from the hook()s anyway?)
|
||||||
|
# -[x] rename to `UnwrappedAddress[Descriptor]` ??
|
||||||
|
# seems like the right name as per the GeeksForGeeks article,
|
||||||
|
# "Introduction to Address Descriptor".
|
||||||
|
#
|
||||||
|
TaggedTCPAddress: TypeAlias = tuple[
|
||||||
|
Literal['tcp'],
|
||||||
|
str,
|
||||||
|
int,
|
||||||
|
]
|
||||||
|
TaggedUnixAddress: TypeAlias = tuple[
|
||||||
|
Literal['unix'],
|
||||||
|
str,
|
||||||
|
]
|
||||||
|
TaggedUDSAlias: TypeAlias = tuple[
|
||||||
|
Literal['uds'],
|
||||||
|
str,
|
||||||
|
]
|
||||||
|
TaggedAddress: TypeAlias = (
|
||||||
|
TaggedTCPAddress
|
||||||
|
|TaggedUnixAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
# Input-only compatibility forms retained for older callers and
|
||||||
|
# serialized payloads.
|
||||||
|
LegacyTCPAddress: TypeAlias = tuple[str, int]
|
||||||
|
LegacyUDSAddress: TypeAlias = tuple[str, str]
|
||||||
|
LegacyUnwrappedAddress: TypeAlias = (
|
||||||
|
LegacyTCPAddress
|
||||||
|
|LegacyUDSAddress
|
||||||
|
)
|
||||||
|
UnwrappedAddress = TaggedAddress
|
||||||
|
|
||||||
|
|
||||||
|
class AddressDeclaration(Protocol):
|
||||||
|
'''
|
||||||
|
Common instance shape of plain and tunnelled addresses.
|
||||||
|
|
||||||
|
This protocol deliberately excludes transport registration and
|
||||||
|
listener operations. A tunnel declaration delegates these address
|
||||||
|
properties to its innermost concrete transport address.
|
||||||
|
|
||||||
|
'''
|
||||||
|
@property
|
||||||
|
def proto_key(self) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def namespace(self) -> tuple[str, str|int]|None:
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bindspace(self) -> str|Path:
|
||||||
|
...
|
||||||
|
|
||||||
|
def unwrap(self) -> UnwrappedAddress:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# TODO, maybe rename to `SocketAddress`?
|
||||||
|
class Address(Protocol):
|
||||||
|
'''
|
||||||
|
Concrete address contract used by IPC transport backends.
|
||||||
|
|
||||||
|
Unlike `AddressDeclaration`, transport registries require
|
||||||
|
class-level protocol metadata. Transport-specific factories stay
|
||||||
|
on their concrete address classes because their descriptor and
|
||||||
|
bindspace inputs differ.
|
||||||
|
|
||||||
|
'''
|
||||||
|
proto_key: ClassVar[str]
|
||||||
|
unwrapped_type: ClassVar[type]
|
||||||
|
|
||||||
|
# TODO, i feel like an `.is_bound()` is a better thing to
|
||||||
|
# support?
|
||||||
|
# Lke, what use does this have besides a noop and if it's not
|
||||||
|
# valid why aren't we erroring on creation/use?
|
||||||
|
@property
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
# TODO, maybe `.netns` is a better name?
|
||||||
|
@property
|
||||||
|
def namespace(self) -> tuple[str, str|int]|None:
|
||||||
|
'''
|
||||||
|
The if-available, OS-specific "network namespace" key.
|
||||||
|
|
||||||
|
'''
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bindspace(self) -> str|Path:
|
||||||
|
'''
|
||||||
|
Deliver the address' transport-specific bindable space.
|
||||||
|
|
||||||
|
'''
|
||||||
|
...
|
||||||
|
|
||||||
|
def unwrap(self) -> UnwrappedAddress:
|
||||||
|
'''
|
||||||
|
Deliver the underlying primitive address descriptor.
|
||||||
|
|
||||||
|
'''
|
||||||
|
...
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'Address',
|
||||||
|
'AddressDeclaration',
|
||||||
|
'LegacyTCPAddress',
|
||||||
|
'LegacyUDSAddress',
|
||||||
|
'LegacyUnwrappedAddress',
|
||||||
|
'TaggedAddress',
|
||||||
|
'TaggedTCPAddress',
|
||||||
|
'TaggedUDSAlias',
|
||||||
|
'TaggedUnixAddress',
|
||||||
|
'UnwrappedAddress',
|
||||||
|
)
|
||||||
|
|
@ -43,12 +43,11 @@ from ._types import (
|
||||||
from tractor.discovery._addr import (
|
from tractor.discovery._addr import (
|
||||||
is_wrapped_addr,
|
is_wrapped_addr,
|
||||||
wrap_address,
|
wrap_address,
|
||||||
Address,
|
|
||||||
UnwrappedAddress,
|
|
||||||
)
|
)
|
||||||
from tractor.discovery._tunnel import (
|
from tractor.discovery.types import (
|
||||||
TunnelledAddress,
|
Address,
|
||||||
strip_tunnels,
|
AddressDeclaration,
|
||||||
|
UnwrappedAddress,
|
||||||
)
|
)
|
||||||
from tractor.log import get_logger
|
from tractor.log import get_logger
|
||||||
from tractor._exceptions import (
|
from tractor._exceptions import (
|
||||||
|
|
@ -186,10 +185,12 @@ class Channel:
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_addr(
|
async def from_addr(
|
||||||
cls,
|
cls,
|
||||||
addr: UnwrappedAddress|Address|TunnelledAddress,
|
addr: UnwrappedAddress|AddressDeclaration,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> Channel:
|
) -> Channel:
|
||||||
|
|
||||||
|
from tractor.net._tunnel import strip_tunnels
|
||||||
|
|
||||||
if not is_wrapped_addr(addr):
|
if not is_wrapped_addr(addr):
|
||||||
addr = wrap_address(addr)
|
addr = wrap_address(addr)
|
||||||
|
|
||||||
|
|
@ -556,7 +557,7 @@ class Channel:
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def _connect_chan(
|
async def _connect_chan(
|
||||||
addr: UnwrappedAddress|Address|TunnelledAddress,
|
addr: UnwrappedAddress|AddressDeclaration,
|
||||||
close_timeout: float|None = None,
|
close_timeout: float|None = None,
|
||||||
) -> typing.AsyncGenerator[Channel, None]:
|
) -> typing.AsyncGenerator[Channel, None]:
|
||||||
'''
|
'''
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,10 @@ from ..msg import (
|
||||||
from ..trionics import maybe_open_nursery
|
from ..trionics import maybe_open_nursery
|
||||||
from ..runtime import _state
|
from ..runtime import _state
|
||||||
from .. import log
|
from .. import log
|
||||||
from ..discovery._addr import (
|
from ..discovery._addr import get_address_cls
|
||||||
|
from ..discovery.types import (
|
||||||
Address,
|
Address,
|
||||||
|
AddressDeclaration,
|
||||||
UnwrappedAddress,
|
UnwrappedAddress,
|
||||||
)
|
)
|
||||||
from ._chan import Channel
|
from ._chan import Channel
|
||||||
|
|
@ -68,7 +70,6 @@ from ._transport import MsgTransport
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..discovery._tunnel import TunnelledAddress
|
|
||||||
from ..runtime._runtime import Actor
|
from ..runtime._runtime import Actor
|
||||||
from ..runtime._supervise import ActorNursery
|
from ..runtime._supervise import ActorNursery
|
||||||
|
|
||||||
|
|
@ -631,7 +632,7 @@ class Endpoint(Struct):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
addr: Address
|
addr: Address
|
||||||
declared_addr: Address|TunnelledAddress
|
declared_addr: AddressDeclaration
|
||||||
listen_tn: Nursery
|
listen_tn: Nursery
|
||||||
stream_handler_tn: Nursery|None = None
|
stream_handler_tn: Nursery|None = None
|
||||||
|
|
||||||
|
|
@ -689,7 +690,8 @@ class Endpoint(Struct):
|
||||||
!=
|
!=
|
||||||
self.addr.unwrap()
|
self.addr.unwrap()
|
||||||
):
|
):
|
||||||
self.addr=self.addr.from_addr(unwrapped)
|
address_type = get_address_cls(self.addr.proto_key)
|
||||||
|
self.addr = address_type.from_addr(unwrapped)
|
||||||
|
|
||||||
self._listener = lstnr
|
self._listener = lstnr
|
||||||
return lstnr
|
return lstnr
|
||||||
|
|
@ -991,7 +993,7 @@ class Server(Struct):
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
accept_addrs: list[
|
accept_addrs: list[
|
||||||
UnwrappedAddress|Address|TunnelledAddress
|
UnwrappedAddress|AddressDeclaration
|
||||||
]|None = None,
|
]|None = None,
|
||||||
stream_handler_nursery: Nursery|None = None,
|
stream_handler_nursery: Nursery|None = None,
|
||||||
) -> list[Endpoint]:
|
) -> list[Endpoint]:
|
||||||
|
|
@ -1075,7 +1077,7 @@ async def _serve_ipc_eps(
|
||||||
*,
|
*,
|
||||||
server: IPCServer,
|
server: IPCServer,
|
||||||
stream_handler_tn: Nursery,
|
stream_handler_tn: Nursery,
|
||||||
listen_addrs: list[Address|TunnelledAddress],
|
listen_addrs: list[AddressDeclaration],
|
||||||
|
|
||||||
task_status: TaskStatus[
|
task_status: TaskStatus[
|
||||||
Nursery,
|
Nursery,
|
||||||
|
|
@ -1091,7 +1093,7 @@ async def _serve_ipc_eps(
|
||||||
`.cancel_server()` is called.
|
`.cancel_server()` is called.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from ..discovery._tunnel import strip_tunnels
|
from ..net._tunnel import strip_tunnels
|
||||||
|
|
||||||
try:
|
try:
|
||||||
listen_tn: Nursery
|
listen_tn: Nursery
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ TCP implementation of tractor.ipc._transport.MsgTransport protocol
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import ipaddress
|
import ipaddress
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
|
||||||
ClassVar,
|
ClassVar,
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
|
|
@ -37,20 +36,16 @@ from trio import (
|
||||||
|
|
||||||
from tractor.msg import MsgCodec
|
from tractor.msg import MsgCodec
|
||||||
from tractor.log import get_logger
|
from tractor.log import get_logger
|
||||||
from tractor.discovery._multiaddr import mk_maddr
|
|
||||||
from tractor.ipc._transport import (
|
from tractor.ipc._transport import (
|
||||||
MsgTransport,
|
MsgTransport,
|
||||||
MsgpackTransport,
|
MsgpackTransport,
|
||||||
)
|
)
|
||||||
|
from tractor.discovery.types import TaggedTCPAddress
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# ONLY type-annots, the eager import costs
|
# ONLY type-annots, the eager import costs
|
||||||
# `import tractor` wall-time (gh #470).
|
# `import tractor` wall-time (gh #470).
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
from tractor.discovery._addr import TaggedTCPAddress
|
|
||||||
else:
|
|
||||||
Multiaddr = Any
|
|
||||||
TaggedTCPAddress = Any
|
|
||||||
|
|
||||||
|
|
||||||
log = get_logger()
|
log = get_logger()
|
||||||
|
|
@ -235,6 +230,8 @@ class MsgpackTCPStream(MsgpackTransport):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def maddr(self) -> Multiaddr:
|
def maddr(self) -> Multiaddr:
|
||||||
|
from tractor.net import mk_maddr
|
||||||
|
|
||||||
return mk_maddr(self.raddr)
|
return mk_maddr(self.raddr)
|
||||||
|
|
||||||
def connected(self) -> bool:
|
def connected(self) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,7 @@ from tractor.msg import (
|
||||||
types as msgtypes,
|
types as msgtypes,
|
||||||
pretty_struct,
|
pretty_struct,
|
||||||
)
|
)
|
||||||
|
from tractor.discovery.types import Address
|
||||||
if TYPE_CHECKING:
|
|
||||||
from tractor.discovery._addr import Address
|
|
||||||
|
|
||||||
log = get_logger()
|
log = get_logger()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ except ImportError:
|
||||||
AF_UNIX = None
|
AF_UNIX = None
|
||||||
import struct
|
import struct
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
|
||||||
Type,
|
Type,
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
ClassVar,
|
ClassVar,
|
||||||
|
|
@ -63,7 +62,6 @@ from trio._highlevel_open_unix_stream import (
|
||||||
|
|
||||||
from tractor.msg import MsgCodec
|
from tractor.msg import MsgCodec
|
||||||
from tractor.log import get_logger
|
from tractor.log import get_logger
|
||||||
from tractor.discovery._multiaddr import mk_maddr
|
|
||||||
from tractor.ipc._transport import (
|
from tractor.ipc._transport import (
|
||||||
MsgpackTransport,
|
MsgpackTransport,
|
||||||
)
|
)
|
||||||
|
|
@ -72,17 +70,13 @@ from tractor.runtime._state import (
|
||||||
current_actor,
|
current_actor,
|
||||||
is_root_process,
|
is_root_process,
|
||||||
)
|
)
|
||||||
|
from tractor.discovery.types import TaggedUnixAddress
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# ONLY type-annots, the eager import costs
|
# ONLY type-annots, the eager import costs
|
||||||
# `import tractor` wall-time (gh #470).
|
# `import tractor` wall-time (gh #470).
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
from tractor.discovery._addr import TaggedUnixAddress
|
|
||||||
from tractor.runtime._runtime import Actor
|
from tractor.runtime._runtime import Actor
|
||||||
else:
|
|
||||||
Multiaddr = Any
|
|
||||||
Actor = Any
|
|
||||||
TaggedUnixAddress = Any
|
|
||||||
|
|
||||||
|
|
||||||
# Platform-specific credential passing constants
|
# Platform-specific credential passing constants
|
||||||
|
|
@ -611,6 +605,8 @@ class MsgpackUDSStream(MsgpackTransport):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def maddr(self) -> Multiaddr|str:
|
def maddr(self) -> Multiaddr|str:
|
||||||
|
from tractor.net import mk_maddr
|
||||||
|
|
||||||
if not self.raddr:
|
if not self.raddr:
|
||||||
return '<unknown-peer>'
|
return '<unknown-peer>'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ from .pretty_struct import (
|
||||||
Struct as Struct,
|
Struct as Struct,
|
||||||
)
|
)
|
||||||
from ._local import (
|
from ._local import (
|
||||||
|
FrozenProcessLocal as FrozenProcessLocal,
|
||||||
ProcessLocal as ProcessLocal,
|
ProcessLocal as ProcessLocal,
|
||||||
)
|
)
|
||||||
from ._codec import (
|
from ._codec import (
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ class _ProcessLocalToken:
|
||||||
'''
|
'''
|
||||||
Unsupported msgspec value embedded in every `ProcessLocal`.
|
Unsupported msgspec value embedded in every `ProcessLocal`.
|
||||||
|
|
||||||
|
A sentinel is one private, unique object used as an identity
|
||||||
|
marker instead of application data. Every `ProcessLocal` holds
|
||||||
|
the same `_PROCESS_LOCAL_TOKEN` instance so construction can
|
||||||
|
verify it by identity and msgspec must encounter its unsupported
|
||||||
|
type on encode.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
__slots__ = ()
|
__slots__ = ()
|
||||||
|
|
||||||
|
|
@ -40,15 +46,71 @@ class ProcessLocal(
|
||||||
repr_omit_defaults=True,
|
repr_omit_defaults=True,
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
Generic struct marker which rejects default msgspec encoding.
|
Struct whose `_process_local` field blocks msgspec encoding.
|
||||||
|
|
||||||
The hidden sentinel remains part of the encoded field set, so
|
`_process_local` must remain the unsupported singleton
|
||||||
msgspec encounters `_ProcessLocalToken` and raises `TypeError`
|
`_PROCESS_LOCAL_TOKEN`. Msgspec reaches that field during direct
|
||||||
even when this value is nested inside another supported payload.
|
or nested encoding and raises `TypeError`; replacing it with an
|
||||||
A custom encode hook may explicitly override that safeguard.
|
encodable value or omitting its default would bypass the guard.
|
||||||
|
A custom encode hook may still explicitly override the safeguard.
|
||||||
|
|
||||||
|
Construction therefore rejects a replacement sentinel and any
|
||||||
|
subclass configured with `omit_defaults=True`. A subclass
|
||||||
|
`__post_init__()` is wrapped with checks before and after its
|
||||||
|
body, preventing that hook from skipping or later replacing the
|
||||||
|
sentinel.
|
||||||
|
|
||||||
Keyword-only fields let subclasses add required fields after the
|
Keyword-only fields let subclasses add required fields after the
|
||||||
marker's default sentinel.
|
marker's default sentinel.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
_process_local: _ProcessLocalToken = _PROCESS_LOCAL_TOKEN
|
_process_local: _ProcessLocalToken = _PROCESS_LOCAL_TOKEN
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs: object) -> None:
|
||||||
|
'''
|
||||||
|
Check the sentinel around a subclass post-init hook.
|
||||||
|
|
||||||
|
'''
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
subclass_post_init = cls.__dict__.get('__post_init__')
|
||||||
|
if subclass_post_init is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def checked_post_init(self: ProcessLocal) -> None:
|
||||||
|
ProcessLocal.__post_init__(self)
|
||||||
|
subclass_post_init(self)
|
||||||
|
ProcessLocal.__post_init__(self)
|
||||||
|
|
||||||
|
cls.__post_init__ = checked_post_init
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
'''
|
||||||
|
Require the singleton sentinel and forbid default omission.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if self._process_local is not _PROCESS_LOCAL_TOKEN:
|
||||||
|
raise TypeError(
|
||||||
|
'`ProcessLocal._process_local` must retain its '
|
||||||
|
'internal sentinel'
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.__struct_config__.omit_defaults:
|
||||||
|
raise TypeError(
|
||||||
|
'`ProcessLocal` subclasses may not enable '
|
||||||
|
'`omit_defaults`'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FrozenProcessLocal(
|
||||||
|
ProcessLocal,
|
||||||
|
frozen=True,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Frozen `ProcessLocal` whose struct fields reject reassignment.
|
||||||
|
|
||||||
|
The inherited `_process_local` sentinel still blocks direct and
|
||||||
|
nested encoding. Freezing is shallow: struct fields cannot be
|
||||||
|
replaced, while mutable objects referenced by those fields retain
|
||||||
|
their own mutation semantics.
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
'''
|
||||||
|
Network declarations, bindspaces and tunnels.
|
||||||
|
|
||||||
|
Public symbols are imported and cached on first access so importing
|
||||||
|
this package does not load optional network dependencies.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
|
||||||
|
_SYMBOL_MODULES: dict[str, str] = {
|
||||||
|
'Bindspace': '._bindspace',
|
||||||
|
'BindspaceKind': '._bindspace',
|
||||||
|
'BindspaceLifecycle': '._bindspace',
|
||||||
|
'BindspaceOwnership': '._bindspace',
|
||||||
|
'BindspaceRef': '._bindspace',
|
||||||
|
'BindspaceSpec': '._bindspace',
|
||||||
|
'CURRENT_NETNS': '._bindspace',
|
||||||
|
'attach_netns': '._bindspace',
|
||||||
|
'open_bindspace': '._bindspace',
|
||||||
|
'open_netns': '._bindspace',
|
||||||
|
'TunnelledAddress': '._tunnel',
|
||||||
|
'TunnelSpec': '._tunnel',
|
||||||
|
'WGTunnelSpec': '._tunnel',
|
||||||
|
'WGInterfaceConfig': '._tunnel',
|
||||||
|
'WGPeerConfig': '._tunnel',
|
||||||
|
'WGRole': '._tunnel',
|
||||||
|
'mb_pubkey': '._tunnel',
|
||||||
|
'mk_wg_maddr': '._tunnel',
|
||||||
|
'open_wg_bindspace': '._tunnel',
|
||||||
|
'open_wg_iface': '._tunnel',
|
||||||
|
'parse_wg_maddr': '._tunnel',
|
||||||
|
'read_wg_peers': '._tunnel',
|
||||||
|
'read_wg_pubkey': '._tunnel',
|
||||||
|
'strip_tunnels': '._tunnel',
|
||||||
|
'tunnels_of': '._tunnel',
|
||||||
|
'verify_wg_peer': '._tunnel',
|
||||||
|
'wg8_pubkey': '._tunnel',
|
||||||
|
'mk_maddr': '..discovery._multiaddr',
|
||||||
|
'parse_maddr': '..discovery._multiaddr',
|
||||||
|
'parse_endpoints': '..discovery._multiaddr',
|
||||||
|
}
|
||||||
|
|
||||||
|
__all__: tuple[str, ...] = tuple(_SYMBOL_MODULES)
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> list[str]:
|
||||||
|
'''
|
||||||
|
Advertise the complete lazy public API.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return sorted(set(globals()) | set(__all__))
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> object:
|
||||||
|
'''
|
||||||
|
Import and cache one public network symbol on first access.
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
module_name: str = _SYMBOL_MODULES[name]
|
||||||
|
except KeyError:
|
||||||
|
raise AttributeError(
|
||||||
|
f'module {__name__!r} has no attribute {name!r}'
|
||||||
|
) from None
|
||||||
|
|
||||||
|
value: object = getattr(
|
||||||
|
import_module(module_name, __name__),
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
globals()[name] = value
|
||||||
|
return value
|
||||||
|
|
@ -51,7 +51,7 @@ BindspaceOwnership: TypeAlias = Literal[
|
||||||
]
|
]
|
||||||
|
|
||||||
_NETNS_RUN_DIR: Path = Path('/var/run/netns')
|
_NETNS_RUN_DIR: Path = Path('/var/run/netns')
|
||||||
_SELF_NETNS: Path = Path('/proc/self/ns/net')
|
_THREAD_NETNS: Path = Path('/proc/thread-self/ns/net')
|
||||||
|
|
||||||
CURRENT_NETNS: Final[None] = None
|
CURRENT_NETNS: Final[None] = None
|
||||||
|
|
||||||
|
|
@ -124,7 +124,7 @@ class BindspaceSpec(
|
||||||
Serializable declaration of one requested bindspace.
|
Serializable declaration of one requested bindspace.
|
||||||
|
|
||||||
For a netns spec, `.key = CURRENT_NETNS` selects the calling
|
For a netns spec, `.key = CURRENT_NETNS` selects the calling
|
||||||
process's current namespace without a named-path lookup.
|
thread's current namespace without a named-path lookup.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
kind: BindspaceKind
|
kind: BindspaceKind
|
||||||
|
|
@ -196,7 +196,7 @@ class Bindspace(
|
||||||
'''
|
'''
|
||||||
spec: BindspaceSpec
|
spec: BindspaceSpec
|
||||||
ref: BindspaceRef
|
ref: BindspaceRef
|
||||||
namespace_fd: int|None
|
namespace_fd: int
|
||||||
ownership: BindspaceOwnership
|
ownership: BindspaceOwnership
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
|
|
@ -206,7 +206,7 @@ class Bindspace(
|
||||||
'''
|
'''
|
||||||
spec: BindspaceSpec = self.spec
|
spec: BindspaceSpec = self.spec
|
||||||
ref: BindspaceRef = self.ref
|
ref: BindspaceRef = self.ref
|
||||||
namespace_fd: int|None = self.namespace_fd
|
namespace_fd: int = self.namespace_fd
|
||||||
ownership: BindspaceOwnership = self.ownership
|
ownership: BindspaceOwnership = self.ownership
|
||||||
|
|
||||||
if spec.kind != ref.kind:
|
if spec.kind != ref.kind:
|
||||||
|
|
@ -237,21 +237,20 @@ class Bindspace(
|
||||||
f'`BindspaceSpec.lifecycle={spec.lifecycle!r}` '
|
f'`BindspaceSpec.lifecycle={spec.lifecycle!r}` '
|
||||||
f'requires ownership={expected_ownership!r}!'
|
f'requires ownership={expected_ownership!r}!'
|
||||||
)
|
)
|
||||||
if namespace_fd is not None:
|
if (
|
||||||
if (
|
type(namespace_fd) is not int
|
||||||
type(namespace_fd) is not int
|
or
|
||||||
or
|
namespace_fd < 0
|
||||||
namespace_fd < 0
|
):
|
||||||
):
|
raise ValueError(
|
||||||
raise ValueError(
|
'`namespace_fd` must be a non-negative `int`!'
|
||||||
'`namespace_fd` must be non-negative or `None`!'
|
)
|
||||||
)
|
fd_inode: int = os.fstat(namespace_fd).st_ino
|
||||||
fd_inode: int = os.fstat(namespace_fd).st_ino
|
if ref.inode != fd_inode:
|
||||||
if ref.inode != fd_inode:
|
raise ValueError(
|
||||||
raise ValueError(
|
f'Namespace FD inode {fd_inode} does not match '
|
||||||
f'Namespace FD inode {fd_inode} does not match '
|
f'reference inode {ref.inode}!'
|
||||||
f'reference inode {ref.inode}!'
|
)
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
'''
|
'''
|
||||||
|
|
@ -277,7 +276,7 @@ async def _pin_netns(
|
||||||
'''
|
'''
|
||||||
key: str|None = spec.key
|
key: str|None = spec.key
|
||||||
namespace_path: Path = (
|
namespace_path: Path = (
|
||||||
_SELF_NETNS
|
_THREAD_NETNS
|
||||||
if key is CURRENT_NETNS
|
if key is CURRENT_NETNS
|
||||||
else _NETNS_RUN_DIR / key
|
else _NETNS_RUN_DIR / key
|
||||||
)
|
)
|
||||||
|
|
@ -19,7 +19,8 @@ Tunnelled addresses: an `Address` that rides *inside* a tunnel.
|
||||||
A tunnel (`wg`, and later plain ip-in-udp, `veth`-in-netns, ..) is
|
A tunnel (`wg`, and later plain ip-in-udp, `veth`-in-netns, ..) is
|
||||||
**not** a `MsgTransport`. Its data plane is transparent to the
|
**not** a `MsgTransport`. Its data plane is transparent to the
|
||||||
application's `socket(2)`, so it never gets its own entry in
|
application's `socket(2)`, so it never gets its own entry in
|
||||||
`._addr._address_types` nor a `MsgpackTransport` impl. Instead it
|
`tractor.discovery._addr._address_types` nor a `MsgpackTransport`
|
||||||
|
impl. Instead it
|
||||||
*annotates* an existing L4 addr, and this module carries that
|
*annotates* an existing L4 addr, and this module carries that
|
||||||
annotation beside it.
|
annotation beside it.
|
||||||
|
|
||||||
|
|
@ -92,6 +93,11 @@ import multibase
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from ..msg._local import ProcessLocal
|
from ..msg._local import ProcessLocal
|
||||||
|
from ..discovery.types import (
|
||||||
|
Address,
|
||||||
|
AddressDeclaration,
|
||||||
|
UnwrappedAddress,
|
||||||
|
)
|
||||||
from ._bindspace import (
|
from ._bindspace import (
|
||||||
Bindspace,
|
Bindspace,
|
||||||
BindspaceRef,
|
BindspaceRef,
|
||||||
|
|
@ -102,16 +108,6 @@ from ._bindspace import (
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
|
|
||||||
from ._addr import (
|
|
||||||
Address,
|
|
||||||
UnwrappedAddress,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
Address = Any
|
|
||||||
Multiaddr = Any
|
|
||||||
UnwrappedAddress = Any
|
|
||||||
|
|
||||||
|
|
||||||
class WGTunnelSpec(
|
class WGTunnelSpec(
|
||||||
msgspec.Struct,
|
msgspec.Struct,
|
||||||
frozen=True,
|
frozen=True,
|
||||||
|
|
@ -483,7 +479,7 @@ def _sync_create_wg_iface(
|
||||||
'`tractor[wg]` extra.'
|
'`tractor[wg]` extra.'
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
namespace_fd: int|None = bindspace.namespace_fd
|
namespace_fd: int = bindspace.namespace_fd
|
||||||
ipr: Any = IPRoute(
|
ipr: Any = IPRoute(
|
||||||
netns=namespace_fd,
|
netns=namespace_fd,
|
||||||
flags=0,
|
flags=0,
|
||||||
|
|
@ -878,7 +874,7 @@ class TunnelledAddress(
|
||||||
`strip_tunnels()`.
|
`strip_tunnels()`.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
overlay: Address|TunnelledAddress
|
overlay: AddressDeclaration
|
||||||
tunnel: TunnelSpec
|
tunnel: TunnelSpec
|
||||||
bindspace_ref: BindspaceRef|None = None
|
bindspace_ref: BindspaceRef|None = None
|
||||||
|
|
||||||
|
|
@ -1055,8 +1051,8 @@ def parse_wg_maddr(
|
||||||
]
|
]
|
||||||
match overlay_names:
|
match overlay_names:
|
||||||
case [('ip4' | 'ip6'), 'tcp']:
|
case [('ip4' | 'ip6'), 'tcp']:
|
||||||
from ._multiaddr import parse_maddr
|
from ..discovery._multiaddr import parse_maddr
|
||||||
overlay: Address|TunnelledAddress = parse_maddr(
|
overlay: AddressDeclaration = parse_maddr(
|
||||||
str(overlay_ma)
|
str(overlay_ma)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1161,7 +1157,7 @@ def mk_wg_maddr(
|
||||||
f'/wg/{mb_pubkey(addr.tunnel.peer_pubkey)}'
|
f'/wg/{mb_pubkey(addr.tunnel.peer_pubkey)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
from ._multiaddr import mk_maddr
|
from ..discovery._multiaddr import mk_maddr
|
||||||
overlay_ma: Multiaddr = mk_maddr(addr.overlay)
|
overlay_ma: Multiaddr = mk_maddr(addr.overlay)
|
||||||
return (
|
return (
|
||||||
bearer_ma
|
bearer_ma
|
||||||
|
|
@ -1171,7 +1167,7 @@ def mk_wg_maddr(
|
||||||
|
|
||||||
|
|
||||||
def strip_tunnels(
|
def strip_tunnels(
|
||||||
addr: Address|TunnelledAddress,
|
addr: AddressDeclaration,
|
||||||
) -> Address:
|
) -> Address:
|
||||||
'''
|
'''
|
||||||
Deliver the bindable `Address`, peeling any tunnel
|
Deliver the bindable `Address`, peeling any tunnel
|
||||||
|
|
@ -1190,7 +1186,7 @@ def strip_tunnels(
|
||||||
|
|
||||||
|
|
||||||
def tunnels_of(
|
def tunnels_of(
|
||||||
addr: Address|TunnelledAddress,
|
addr: AddressDeclaration,
|
||||||
) -> tuple[TunnelSpec, ...]:
|
) -> tuple[TunnelSpec, ...]:
|
||||||
'''
|
'''
|
||||||
Deliver every tunnel spec wrapping `addr`, outermost first.
|
Deliver every tunnel spec wrapping `addr`, outermost first.
|
||||||
|
|
@ -60,7 +60,6 @@ import sys
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
Type,
|
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -1684,10 +1683,10 @@ async def async_main(
|
||||||
[_state._def_tpt_proto]
|
[_state._def_tpt_proto]
|
||||||
)
|
)
|
||||||
for transport_key in enable_transports:
|
for transport_key in enable_transports:
|
||||||
transport_cls: Type[Address] = get_address_cls(
|
address_type = get_address_cls(
|
||||||
transport_key
|
transport_key
|
||||||
)
|
)
|
||||||
addr: Address = transport_cls.get_random()
|
addr: Address = address_type.get_random()
|
||||||
accept_addrs.append(addr.unwrap())
|
accept_addrs.append(addr.unwrap())
|
||||||
|
|
||||||
# XXX, either passed in by caller or delivered
|
# XXX, either passed in by caller or delivered
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,16 @@
|
||||||
``trio`` inspired apis and helpers
|
``trio`` inspired apis and helpers
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from contextlib import asynccontextmanager as acm
|
from contextlib import (
|
||||||
|
asynccontextmanager as acm,
|
||||||
|
ExitStack,
|
||||||
|
)
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
import typing
|
import typing
|
||||||
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
|
|
@ -36,6 +40,7 @@ from ..devx import (
|
||||||
from ..discovery._addr import (
|
from ..discovery._addr import (
|
||||||
UnwrappedAddress,
|
UnwrappedAddress,
|
||||||
mk_uuid,
|
mk_uuid,
|
||||||
|
wrap_address,
|
||||||
)
|
)
|
||||||
from ._state import (
|
from ._state import (
|
||||||
current_actor,
|
current_actor,
|
||||||
|
|
@ -65,6 +70,7 @@ from ..spawn import _spawn
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
|
from ..net._bindspace import Bindspace
|
||||||
# from ..ipc._server import IPCServer
|
# from ..ipc._server import IPCServer
|
||||||
from ..ipc import IPCServer
|
from ..ipc import IPCServer
|
||||||
from ..spawn._spawn import ProcessType
|
from ..spawn._spawn import ProcessType
|
||||||
|
|
@ -73,6 +79,22 @@ if TYPE_CHECKING:
|
||||||
log = get_logger()
|
log = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_spawn_address(addr: UnwrappedAddress) -> None:
|
||||||
|
'''
|
||||||
|
Require the canonical tagged address boundary for spawn routing.
|
||||||
|
|
||||||
|
'''
|
||||||
|
valid = False
|
||||||
|
if type(addr) is tuple:
|
||||||
|
match addr:
|
||||||
|
case ('tcp', str() as host, port):
|
||||||
|
valid = bool(host) and type(port) is int and 0 < port < 65536
|
||||||
|
case ('unix', str() as path):
|
||||||
|
valid = bool(path) and '\0' not in path
|
||||||
|
if not valid or wrap_address(addr).unwrap() != addr:
|
||||||
|
raise ValueError(f'Expected a canonical UnwrappedAddress, got {addr!r}')
|
||||||
|
|
||||||
|
|
||||||
async def _try_cancel_then_kill(
|
async def _try_cancel_then_kill(
|
||||||
portal: Portal,
|
portal: Portal,
|
||||||
# `ProcessType` is `TYPE_CHECKING`-only (defined under that
|
# `ProcessType` is `TYPE_CHECKING`-only (defined under that
|
||||||
|
|
@ -419,6 +441,9 @@ class ActorNursery:
|
||||||
*,
|
*,
|
||||||
|
|
||||||
bind_addrs: list[UnwrappedAddress]|None = None,
|
bind_addrs: list[UnwrappedAddress]|None = None,
|
||||||
|
bindspace: 'Bindspace|None' = None,
|
||||||
|
parent_addr: UnwrappedAddress|None = None,
|
||||||
|
registry_addrs: list[UnwrappedAddress]|None = None,
|
||||||
rpc_module_paths: list[str]|None = None,
|
rpc_module_paths: list[str]|None = None,
|
||||||
enable_transports: list[str] = [_state._def_tpt_proto],
|
enable_transports: list[str] = [_state._def_tpt_proto],
|
||||||
enable_modules: list[str]|None = None,
|
enable_modules: list[str]|None = None,
|
||||||
|
|
@ -433,6 +458,12 @@ class ActorNursery:
|
||||||
Start a (daemon) actor: an process that has no designated
|
Start a (daemon) actor: an process that has no designated
|
||||||
"main task" besides the runtime.
|
"main task" besides the runtime.
|
||||||
|
|
||||||
|
`parent_addr` selects one canonical live supervisor listener;
|
||||||
|
`registry_addrs` replaces registrar inheritance for this child.
|
||||||
|
Both must remain reachable from the child's target netns. A
|
||||||
|
filesystem UDS is the usual cross-netns bootstrap transport.
|
||||||
|
Omitted `bindspace` on Linux/Trio pins the calling task's netns.
|
||||||
|
|
||||||
Pass ``inherit_parent_main=False`` to keep this child on its
|
Pass ``inherit_parent_main=False`` to keep this child on its
|
||||||
own bootstrap module for the trio spawn backend instead of
|
own bootstrap module for the trio spawn backend instead of
|
||||||
applying the parent ``__main__`` re-exec fixup during startup.
|
applying the parent ``__main__`` re-exec fixup during startup.
|
||||||
|
|
@ -476,6 +507,27 @@ class ActorNursery:
|
||||||
)
|
)
|
||||||
enable_modules.extend(rpc_module_paths)
|
enable_modules.extend(rpc_module_paths)
|
||||||
|
|
||||||
|
if parent_addr is None:
|
||||||
|
parent_addr = self._actor.accept_addr
|
||||||
|
else:
|
||||||
|
_validate_spawn_address(parent_addr)
|
||||||
|
if parent_addr not in self._actor.accept_addrs:
|
||||||
|
raise ValueError(
|
||||||
|
f'parent_addr {parent_addr!r} must name a live '
|
||||||
|
'supervisor listener'
|
||||||
|
)
|
||||||
|
assert parent_addr
|
||||||
|
|
||||||
|
if registry_addrs is None:
|
||||||
|
selected_reg_addrs = list(current_actor().reg_addrs)
|
||||||
|
else:
|
||||||
|
if type(registry_addrs) is not list or not registry_addrs:
|
||||||
|
raise ValueError('registry_addrs must be a non-empty list')
|
||||||
|
for addr in registry_addrs:
|
||||||
|
_validate_spawn_address(addr)
|
||||||
|
selected_reg_addrs = list(registry_addrs)
|
||||||
|
_rtv['_registry_addrs'] = list(selected_reg_addrs)
|
||||||
|
|
||||||
subactor = Actor(
|
subactor = Actor(
|
||||||
name=name,
|
name=name,
|
||||||
uuid=mk_uuid(),
|
uuid=mk_uuid(),
|
||||||
|
|
@ -486,28 +538,41 @@ class ActorNursery:
|
||||||
inherit_parent_main=inherit_parent_main,
|
inherit_parent_main=inherit_parent_main,
|
||||||
|
|
||||||
# verbatim relay this actor's registrar addresses
|
# verbatim relay this actor's registrar addresses
|
||||||
registry_addrs=current_actor().registry_addrs,
|
# Populate child-local state below: Actor construction with
|
||||||
|
# registry_addrs publishes into this process's runtime vars.
|
||||||
)
|
)
|
||||||
parent_addr: UnwrappedAddress = self._actor.accept_addr
|
subactor.reg_addrs = selected_reg_addrs
|
||||||
assert parent_addr
|
|
||||||
|
|
||||||
# start a task to spawn a process
|
# start a task to spawn a process
|
||||||
# blocks until process has been started and a portal setup
|
# blocks until process has been started and a portal setup
|
||||||
# XXX: the type ignore is actually due to a `mypy` bug
|
# XXX: the type ignore is actually due to a `mypy` bug
|
||||||
return await self._da_nursery.start( # type: ignore
|
with ExitStack() as scope:
|
||||||
partial(
|
if (
|
||||||
_spawn.new_proc,
|
bindspace is None
|
||||||
name,
|
and sys.platform == 'linux'
|
||||||
self,
|
and _spawn._spawn_method == 'trio'
|
||||||
subactor,
|
):
|
||||||
self.errors,
|
from ..spawn._netns import pin_current_netns
|
||||||
bind_addrs,
|
|
||||||
parent_addr,
|
# Capture on the calling actor task before nursery.start()
|
||||||
_rtv, # run time vars
|
# checkpoints or any subprocess worker can choose a netns.
|
||||||
infect_asyncio=infect_asyncio,
|
bindspace = scope.enter_context(pin_current_netns())
|
||||||
proc_kwargs=proc_kwargs
|
|
||||||
|
return await self._da_nursery.start( # type: ignore
|
||||||
|
partial(
|
||||||
|
_spawn.new_proc,
|
||||||
|
name,
|
||||||
|
self,
|
||||||
|
subactor,
|
||||||
|
self.errors,
|
||||||
|
bind_addrs,
|
||||||
|
parent_addr,
|
||||||
|
_rtv, # run time vars
|
||||||
|
bindspace=bindspace,
|
||||||
|
infect_asyncio=infect_asyncio,
|
||||||
|
proc_kwargs=proc_kwargs
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# @api_frame
|
# @api_frame
|
||||||
async def cancel(
|
async def cancel(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ Sub-process entry points.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
|
import os
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
|
|
@ -48,6 +49,7 @@ from ..runtime._runtime import (
|
||||||
async_main,
|
async_main,
|
||||||
Actor,
|
Actor,
|
||||||
)
|
)
|
||||||
|
from ._netns import enter_netns
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ._spawn import SpawnMethodKey
|
from ._spawn import SpawnMethodKey
|
||||||
|
|
@ -56,6 +58,57 @@ if TYPE_CHECKING:
|
||||||
log = get_logger()
|
log = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_netns_bootstrap(
|
||||||
|
netns_bootstrap: tuple[int, int, int]|None,
|
||||||
|
) -> int|None:
|
||||||
|
'''
|
||||||
|
Enter and release one child-owned network namespace capability.
|
||||||
|
|
||||||
|
The FD must be an exclusively child-owned backend duplicate. It is
|
||||||
|
closed whether namespace entry succeeds or fails, before any actor
|
||||||
|
runtime setup can continue.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if netns_bootstrap is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
namespace_fd: int
|
||||||
|
expected_device: int
|
||||||
|
expected_inode: int
|
||||||
|
namespace_fd, expected_device, expected_inode = netns_bootstrap
|
||||||
|
if (
|
||||||
|
type(namespace_fd) is not int
|
||||||
|
or
|
||||||
|
namespace_fd < 0
|
||||||
|
):
|
||||||
|
# Let `enter_netns()` report its precise validation error, but
|
||||||
|
# never pass bool/non-int/negative values to `os.close()`.
|
||||||
|
return enter_netns(
|
||||||
|
namespace_fd,
|
||||||
|
expected_device,
|
||||||
|
expected_inode,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
entered_inode: int = enter_netns(
|
||||||
|
namespace_fd,
|
||||||
|
expected_device,
|
||||||
|
expected_inode,
|
||||||
|
)
|
||||||
|
except BaseException as entry_error:
|
||||||
|
try:
|
||||||
|
os.close(namespace_fd)
|
||||||
|
except Exception as close_error:
|
||||||
|
entry_error.add_note(
|
||||||
|
f'Also failed to close inherited namespace FD '
|
||||||
|
f'{namespace_fd}: {close_error!r}'
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
os.close(namespace_fd)
|
||||||
|
return entered_inode
|
||||||
|
|
||||||
|
|
||||||
def _mp_main(
|
def _mp_main(
|
||||||
|
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
|
|
@ -64,12 +117,18 @@ def _mp_main(
|
||||||
start_method: SpawnMethodKey,
|
start_method: SpawnMethodKey,
|
||||||
parent_addr: UnwrappedAddress | None = None,
|
parent_addr: UnwrappedAddress | None = None,
|
||||||
infect_asyncio: bool = False,
|
infect_asyncio: bool = False,
|
||||||
|
netns_bootstrap: tuple[int, int, int]|None = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
The routine called *after fork* which invokes a fresh `trio.run()`
|
The routine called *after fork* which invokes a fresh `trio.run()`
|
||||||
|
|
||||||
|
Consume `netns_bootstrap` before multiprocessing or actor-runtime
|
||||||
|
setup. The spawn backend must supply a child-owned FD duplicate.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
_consume_netns_bootstrap(netns_bootstrap)
|
||||||
|
|
||||||
actor._forkserver_info = forkserver_info
|
actor._forkserver_info = forkserver_info
|
||||||
from ._spawn import try_set_start_method
|
from ._spawn import try_set_start_method
|
||||||
spawn_ctx: mp.context.BaseContext = try_set_start_method(start_method)
|
spawn_ctx: mp.context.BaseContext = try_set_start_method(start_method)
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ from ._spawn import (
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from tractor.net._bindspace import Bindspace
|
||||||
from tractor.ipc import (
|
from tractor.ipc import (
|
||||||
_server,
|
_server,
|
||||||
)
|
)
|
||||||
|
|
@ -73,12 +74,19 @@ async def mp_proc(
|
||||||
parent_addr: UnwrappedAddress,
|
parent_addr: UnwrappedAddress,
|
||||||
_runtime_vars: dict[str, Any], # serialized and sent to _child
|
_runtime_vars: dict[str, Any], # serialized and sent to _child
|
||||||
*,
|
*,
|
||||||
|
bindspace: Bindspace|None = None,
|
||||||
infect_asyncio: bool = False,
|
infect_asyncio: bool = False,
|
||||||
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED,
|
||||||
proc_kwargs: dict[str, any] = {}
|
proc_kwargs: dict[str, any] = {}
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
|
if bindspace is not None:
|
||||||
|
raise NotImplementedError(
|
||||||
|
'Network namespace bindspace transport is not yet '
|
||||||
|
'supported by multiprocessing spawn backends!'
|
||||||
|
)
|
||||||
|
|
||||||
# uggh zone
|
# uggh zone
|
||||||
try:
|
try:
|
||||||
from multiprocessing import semaphore_tracker # type: ignore
|
from multiprocessing import semaphore_tracker # type: ignore
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,317 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
'''
|
||||||
|
Linux network-namespace actor-bootstrap primitives.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import (
|
||||||
|
Callable,
|
||||||
|
Iterator,
|
||||||
|
)
|
||||||
|
from contextlib import contextmanager as cm
|
||||||
|
import errno
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..net._bindspace import Bindspace
|
||||||
|
|
||||||
|
|
||||||
|
# `setns(2)` mutates only the calling thread's namespace:
|
||||||
|
# https://man7.org/linux/man-pages/man2/setns.2.html
|
||||||
|
# `/proc/thread-self` addresses the caller's current task:
|
||||||
|
# https://man7.org/linux/man-pages/man5/proc_pid_task.5.html
|
||||||
|
# Do not use `/proc/self` because it resolves through the process
|
||||||
|
# leader.
|
||||||
|
_SELF_NETNS: Path = Path('/proc/thread-self/ns/net')
|
||||||
|
|
||||||
|
|
||||||
|
def enter_netns(
|
||||||
|
namespace_fd: int,
|
||||||
|
expected_device: int,
|
||||||
|
expected_inode: int,
|
||||||
|
) -> int:
|
||||||
|
'''
|
||||||
|
Enter and verify one inherited Linux network namespace.
|
||||||
|
|
||||||
|
The caller owns and closes `namespace_fd`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if sys.platform != 'linux':
|
||||||
|
raise RuntimeError(
|
||||||
|
'Network namespace entry is Linux-only!'
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
type(namespace_fd) is not int
|
||||||
|
or
|
||||||
|
namespace_fd < 0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
'`namespace_fd` must be a non-negative `int`!'
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
type(expected_inode) is not int
|
||||||
|
or
|
||||||
|
expected_inode <= 0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
'`expected_inode` must be a positive `int`!'
|
||||||
|
)
|
||||||
|
|
||||||
|
if type(expected_device) is not int or expected_device < 0:
|
||||||
|
raise ValueError('`expected_device` must be a non-negative `int`!')
|
||||||
|
|
||||||
|
target = os.fstat(namespace_fd)
|
||||||
|
expected = (expected_device, expected_inode)
|
||||||
|
if (target.st_dev, target.st_ino) != expected:
|
||||||
|
raise ValueError(
|
||||||
|
f'Inherited namespace FD identity '
|
||||||
|
f'{(target.st_dev, target.st_ino)} does not '
|
||||||
|
f'match expected identity {expected}!'
|
||||||
|
)
|
||||||
|
|
||||||
|
current = _SELF_NETNS.stat()
|
||||||
|
if (current.st_dev, current.st_ino) == expected:
|
||||||
|
return expected_inode
|
||||||
|
|
||||||
|
setns: Callable[[int, int], None]|None = getattr(
|
||||||
|
os,
|
||||||
|
'setns',
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
clone_newnet: int|None = getattr(
|
||||||
|
os,
|
||||||
|
'CLONE_NEWNET',
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
setns is None
|
||||||
|
or
|
||||||
|
clone_newnet is None
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
'Python has no Linux network namespace entry support!'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
setns(namespace_fd, clone_newnet)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Could not enter network namespace inode '
|
||||||
|
f'{expected_inode}!'
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
entered = _SELF_NETNS.stat()
|
||||||
|
entered_inode: int = entered.st_ino
|
||||||
|
if (entered.st_dev, entered_inode) != expected:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Entered network namespace identity '
|
||||||
|
f'{(entered.st_dev, entered_inode)} does not '
|
||||||
|
f'match expected identity {expected}!'
|
||||||
|
)
|
||||||
|
|
||||||
|
return entered_inode
|
||||||
|
|
||||||
|
|
||||||
|
@cm
|
||||||
|
def close_fd(
|
||||||
|
owned_fd: int,
|
||||||
|
fd_name: str,
|
||||||
|
) -> Iterator[None]:
|
||||||
|
'''
|
||||||
|
Close one owned netns FD without masking a prior error.
|
||||||
|
|
||||||
|
'''
|
||||||
|
operation: str = (
|
||||||
|
f'close owned {fd_name} netns FD {owned_fd}'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except BaseException as primary_error:
|
||||||
|
try:
|
||||||
|
os.close(owned_fd)
|
||||||
|
except BaseException as close_error:
|
||||||
|
primary_error.add_note(
|
||||||
|
f'Also failed to {operation}: {close_error!r}'
|
||||||
|
)
|
||||||
|
raise primary_error
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.close(owned_fd)
|
||||||
|
except BaseException as close_error:
|
||||||
|
close_error.add_note(
|
||||||
|
f'Failed to {operation} during root netns cleanup.'
|
||||||
|
)
|
||||||
|
raise close_error
|
||||||
|
|
||||||
|
|
||||||
|
@cm
|
||||||
|
def dup_fd(
|
||||||
|
source_fd: int,
|
||||||
|
) -> Iterator[int]:
|
||||||
|
'''
|
||||||
|
Duplicate and own the target netns FD for this context.
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
owned_fd: int = os.dup(source_fd)
|
||||||
|
except OSError as dup_error:
|
||||||
|
if dup_error.errno != errno.EBADF:
|
||||||
|
raise dup_error
|
||||||
|
raise ValueError(
|
||||||
|
'`bindspace.namespace_fd` does not reference '
|
||||||
|
'a live FD!'
|
||||||
|
) from dup_error
|
||||||
|
|
||||||
|
with close_fd(owned_fd, 'target'):
|
||||||
|
yield owned_fd
|
||||||
|
|
||||||
|
|
||||||
|
@cm
|
||||||
|
def _enter_netns_temporarily(
|
||||||
|
bindspace: Bindspace|None,
|
||||||
|
) -> Iterator[int|None]:
|
||||||
|
'''
|
||||||
|
Enter a root bindspace and restore the caller thread's netns.
|
||||||
|
|
||||||
|
Internal synchronous primitive only. Never surround a Trio
|
||||||
|
checkpoint with this scope: entry changes only the calling thread.
|
||||||
|
The former async root adapter has been removed.
|
||||||
|
|
||||||
|
Only descriptors opened or duplicated by this context are used
|
||||||
|
for validation, entry and restoration. Since `setns()` is
|
||||||
|
thread-local, this synchronous context performs no checkpoints
|
||||||
|
around either transition.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if bindspace is None:
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
|
||||||
|
if sys.platform != 'linux':
|
||||||
|
raise RuntimeError(
|
||||||
|
'Network namespace entry is Linux-only!'
|
||||||
|
)
|
||||||
|
|
||||||
|
namespace_fd: int = bindspace.namespace_fd
|
||||||
|
if (
|
||||||
|
type(namespace_fd) is not int
|
||||||
|
or
|
||||||
|
namespace_fd < 0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
'`bindspace.namespace_fd` must be a live '
|
||||||
|
'non-negative FD!'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Borrow `Bindspace.namespace_fd`; duplicate it so this context
|
||||||
|
# owns target cleanup and cannot close the caller's capability.
|
||||||
|
# Nested FD scopes aggregate later close failures as notes on the
|
||||||
|
# first body, restoration or cleanup error.
|
||||||
|
with dup_fd(namespace_fd) as tgt_fd:
|
||||||
|
tgt_stat: os.stat_result = os.fstat(tgt_fd)
|
||||||
|
tgt_inode: int = bindspace.ref.inode
|
||||||
|
if tgt_stat.st_ino != tgt_inode:
|
||||||
|
raise ValueError(
|
||||||
|
f'Target namespace FD inode '
|
||||||
|
f'{tgt_stat.st_ino} does '
|
||||||
|
f'not match bindspace inode {tgt_inode}!'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capture the calling thread's current netns before any
|
||||||
|
# transition. It need not be the process's initial netns. This
|
||||||
|
# context owns the snapshot FD even when no transition is
|
||||||
|
# needed, so keep it live through restoration and always close
|
||||||
|
# it afterward.
|
||||||
|
orig_fd = os.open(
|
||||||
|
_SELF_NETNS,
|
||||||
|
os.O_RDONLY | os.O_CLOEXEC,
|
||||||
|
)
|
||||||
|
with close_fd(orig_fd, 'original'):
|
||||||
|
orig_stat: os.stat_result = os.fstat(orig_fd)
|
||||||
|
orig_inode: int = orig_stat.st_ino
|
||||||
|
restore_needed: bool = (
|
||||||
|
tgt_stat.st_dev != orig_stat.st_dev
|
||||||
|
or
|
||||||
|
tgt_inode != orig_inode
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if restore_needed:
|
||||||
|
enter_netns(
|
||||||
|
tgt_fd,
|
||||||
|
tgt_stat.st_dev,
|
||||||
|
tgt_inode,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield tgt_inode
|
||||||
|
|
||||||
|
except BaseException as primary_error:
|
||||||
|
if restore_needed:
|
||||||
|
try:
|
||||||
|
enter_netns(
|
||||||
|
orig_fd,
|
||||||
|
orig_stat.st_dev,
|
||||||
|
orig_inode,
|
||||||
|
)
|
||||||
|
except BaseException as restore_error:
|
||||||
|
primary_error.add_note(
|
||||||
|
'Also failed to restore the original '
|
||||||
|
'network namespace: '
|
||||||
|
f'{restore_error!r}'
|
||||||
|
)
|
||||||
|
raise primary_error
|
||||||
|
|
||||||
|
if restore_needed:
|
||||||
|
try:
|
||||||
|
enter_netns(
|
||||||
|
orig_fd,
|
||||||
|
orig_stat.st_dev,
|
||||||
|
orig_inode,
|
||||||
|
)
|
||||||
|
except BaseException as restore_error:
|
||||||
|
restore_error.add_note(
|
||||||
|
'Failed to restore the original network '
|
||||||
|
'namespace during root netns cleanup.'
|
||||||
|
)
|
||||||
|
raise restore_error
|
||||||
|
|
||||||
|
|
||||||
|
@cm
|
||||||
|
def pin_current_netns() -> Iterator[Bindspace]:
|
||||||
|
'''
|
||||||
|
Pin the calling task's netns synchronously, without entering it.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from ..net._bindspace import Bindspace, BindspaceRef, BindspaceSpec
|
||||||
|
|
||||||
|
namespace_fd = os.open(_SELF_NETNS, os.O_RDONLY | os.O_CLOEXEC)
|
||||||
|
with close_fd(namespace_fd, 'calling task'):
|
||||||
|
yield Bindspace(
|
||||||
|
spec=BindspaceSpec(kind='netns'),
|
||||||
|
ref=BindspaceRef(
|
||||||
|
kind='netns',
|
||||||
|
key=None,
|
||||||
|
inode=os.fstat(namespace_fd).st_ino,
|
||||||
|
),
|
||||||
|
namespace_fd=namespace_fd,
|
||||||
|
ownership='borrowed',
|
||||||
|
)
|
||||||
|
|
@ -34,6 +34,7 @@ from typing import (
|
||||||
import trio
|
import trio
|
||||||
from trio import TaskStatus
|
from trio import TaskStatus
|
||||||
|
|
||||||
|
from .._exceptions import ActorFailure
|
||||||
from ..devx import debug
|
from ..devx import debug
|
||||||
from tractor.runtime._state import (
|
from tractor.runtime._state import (
|
||||||
_runtime_vars,
|
_runtime_vars,
|
||||||
|
|
@ -49,7 +50,9 @@ from tractor.msg import types as msgtypes
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from tractor.net._bindspace import Bindspace
|
||||||
from tractor.ipc import (
|
from tractor.ipc import (
|
||||||
|
_server,
|
||||||
Channel,
|
Channel,
|
||||||
)
|
)
|
||||||
from tractor.runtime._supervise import ActorNursery
|
from tractor.runtime._supervise import ActorNursery
|
||||||
|
|
@ -81,6 +84,101 @@ else:
|
||||||
await trio.lowlevel.wait_readable(proc.sentinel)
|
await trio.lowlevel.wait_readable(proc.sentinel)
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_peer_or_proc_death(
|
||||||
|
ipc_server: _server.Server,
|
||||||
|
uid: tuple[str, str],
|
||||||
|
proc_wait: Callable[[], Awaitable[int]],
|
||||||
|
proc_repr: object = '',
|
||||||
|
) -> tuple[trio.Event, Channel]:
|
||||||
|
'''
|
||||||
|
Race a child handshake against process death during bootstrap.
|
||||||
|
|
||||||
|
A child can exit before connecting to its parent. Waiting only on
|
||||||
|
`IPCServer.wait_for_peer()` would then park its spawning task and
|
||||||
|
leave the dead process unreaped. Run both waits in one nursery and
|
||||||
|
let either completed result cancel its sibling.
|
||||||
|
|
||||||
|
Return the normal peer event and channel when the handshake wins.
|
||||||
|
Raise `ActorFailure` with the process status when death wins.
|
||||||
|
|
||||||
|
Adapted from goodboy's Claude Code-assisted implementation in
|
||||||
|
commit `3b0724eba85b4014170ed95773e1e41a60d5c513`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
handshake: tuple[trio.Event, Channel]|None = None
|
||||||
|
handshake_error: BaseException|None = None
|
||||||
|
returncode: int|None = None
|
||||||
|
death_error: BaseException|None = None
|
||||||
|
|
||||||
|
async def wait_for_handshake() -> None:
|
||||||
|
'''
|
||||||
|
Publish a connected peer before cancelling the death waiter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
nonlocal handshake
|
||||||
|
nonlocal handshake_error
|
||||||
|
try:
|
||||||
|
handshake = await ipc_server.wait_for_peer(uid)
|
||||||
|
except trio.Cancelled:
|
||||||
|
if (
|
||||||
|
returncode is not None
|
||||||
|
or
|
||||||
|
death_error is not None
|
||||||
|
):
|
||||||
|
log.debug(
|
||||||
|
'Peer-handshake waiter cancelled after '
|
||||||
|
f'process wait completed for {uid!r}'
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except BaseException as exc:
|
||||||
|
handshake_error = exc
|
||||||
|
nursery.cancel_scope.cancel()
|
||||||
|
|
||||||
|
async def wait_for_death() -> None:
|
||||||
|
'''
|
||||||
|
Publish child exit before cancelling the handshake waiter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
nonlocal returncode
|
||||||
|
nonlocal death_error
|
||||||
|
try:
|
||||||
|
returncode = await proc_wait()
|
||||||
|
except trio.Cancelled:
|
||||||
|
if (
|
||||||
|
handshake is not None
|
||||||
|
or
|
||||||
|
handshake_error is not None
|
||||||
|
):
|
||||||
|
log.debug(
|
||||||
|
'Process-death waiter cancelled after '
|
||||||
|
f'peer wait completed for {uid!r}'
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except BaseException as exc:
|
||||||
|
death_error = exc
|
||||||
|
nursery.cancel_scope.cancel()
|
||||||
|
|
||||||
|
async with trio.open_nursery() as nursery:
|
||||||
|
nursery.start_soon(wait_for_handshake)
|
||||||
|
nursery.start_soon(wait_for_death)
|
||||||
|
|
||||||
|
if handshake_error is not None:
|
||||||
|
raise handshake_error
|
||||||
|
if death_error is not None:
|
||||||
|
raise death_error
|
||||||
|
|
||||||
|
if returncode is not None:
|
||||||
|
raise ActorFailure(
|
||||||
|
f'Sub-actor {uid!r} died during boot '
|
||||||
|
f'(rc={returncode!r}) before completing '
|
||||||
|
f'parent-handshake.\n'
|
||||||
|
f' proc: {proc_repr}'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert handshake is not None
|
||||||
|
return handshake
|
||||||
|
|
||||||
|
|
||||||
def try_set_start_method(
|
def try_set_start_method(
|
||||||
key: SpawnMethodKey
|
key: SpawnMethodKey
|
||||||
|
|
||||||
|
|
@ -342,6 +440,7 @@ async def new_proc(
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
|
||||||
|
bindspace: Bindspace|None = None,
|
||||||
infect_asyncio: bool = False,
|
infect_asyncio: bool = False,
|
||||||
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED,
|
||||||
proc_kwargs: dict[str, any] = {}
|
proc_kwargs: dict[str, any] = {}
|
||||||
|
|
@ -363,6 +462,7 @@ async def new_proc(
|
||||||
bind_addrs,
|
bind_addrs,
|
||||||
parent_addr,
|
parent_addr,
|
||||||
_runtime_vars, # run time vars
|
_runtime_vars, # run time vars
|
||||||
|
bindspace=bindspace,
|
||||||
infect_asyncio=infect_asyncio,
|
infect_asyncio=infect_asyncio,
|
||||||
task_status=task_status,
|
task_status=task_status,
|
||||||
proc_kwargs=proc_kwargs
|
proc_kwargs=proc_kwargs
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ Spawns sub-actors as fresh OS processes driven by
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
|
|
@ -51,10 +52,12 @@ from tractor.msg import (
|
||||||
from ._spawn import (
|
from ._spawn import (
|
||||||
hard_kill,
|
hard_kill,
|
||||||
soft_kill,
|
soft_kill,
|
||||||
|
wait_for_peer_or_proc_death,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from tractor.net._bindspace import Bindspace
|
||||||
from tractor.ipc import (
|
from tractor.ipc import (
|
||||||
_server,
|
_server,
|
||||||
)
|
)
|
||||||
|
|
@ -75,29 +78,31 @@ async def trio_proc(
|
||||||
parent_addr: UnwrappedAddress,
|
parent_addr: UnwrappedAddress,
|
||||||
_runtime_vars: dict[str, Any], # serialized and sent to _child
|
_runtime_vars: dict[str, Any], # serialized and sent to _child
|
||||||
*,
|
*,
|
||||||
|
bindspace: Bindspace|None = None,
|
||||||
infect_asyncio: bool = False,
|
infect_asyncio: bool = False,
|
||||||
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED,
|
||||||
proc_kwargs: dict[str, any] = {}
|
proc_kwargs: dict[str, any] = {}
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
Create a new ``Process`` using a "spawn method" as (configured using
|
Create a new ``Process`` using a "spawn method" as (configured
|
||||||
``try_set_start_method()``).
|
using ``try_set_start_method()``).
|
||||||
|
|
||||||
This routine should be started in a actor runtime task and the logic
|
This routine should be started in a actor runtime task and the
|
||||||
here is to be considered the core supervision strategy.
|
logic here is to be considered the core supervision strategy.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
spawn_cmd = [
|
spawn_cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
# Hardcode this (instead of using ``_child.__name__`` to avoid a
|
# Hardcode this (instead of using ``_child.__name__`` to
|
||||||
# double import warning: https://stackoverflow.com/a/45070583
|
# avoid a double import warning:
|
||||||
|
# https://stackoverflow.com/a/45070583
|
||||||
"tractor._child",
|
"tractor._child",
|
||||||
# We provide the child's unique identifier on this exec/spawn
|
# We provide the child's unique identifier on this exec/spawn
|
||||||
# line for debugging purposes when viewing the process tree from
|
# line for debugging purposes when viewing the process tree
|
||||||
# the OS; it otherwise can be passed via the parent channel if
|
# from the OS; it otherwise can be passed via the parent
|
||||||
# we prefer in the future (for privacy).
|
# channel if we prefer in the future (for privacy).
|
||||||
"--uid",
|
"--uid",
|
||||||
# TODO, how to pass this over "wire" encodings like
|
# TODO, how to pass this over "wire" encodings like
|
||||||
# cmdline args?
|
# cmdline args?
|
||||||
|
|
@ -115,20 +120,77 @@ async def trio_proc(
|
||||||
]
|
]
|
||||||
# Tell child to run in guest mode on top of ``asyncio`` loop
|
# Tell child to run in guest mode on top of ``asyncio`` loop
|
||||||
if infect_asyncio:
|
if infect_asyncio:
|
||||||
spawn_cmd.append("--asyncio")
|
spawn_cmd.append('--asyncio')
|
||||||
|
|
||||||
|
child_netns_fd: int|None = None
|
||||||
|
if bindspace is not None:
|
||||||
|
namespace_fd: int = bindspace.namespace_fd
|
||||||
|
|
||||||
|
# Snapshot caller-owned process options before duplicating the
|
||||||
|
# live `Bindspace.namespace_fd`. No checkpoint separates this
|
||||||
|
# setup from `open_process()` below.
|
||||||
|
inherited_fds: tuple[int, ...] = tuple(
|
||||||
|
proc_kwargs.get('pass_fds', ())
|
||||||
|
)
|
||||||
|
proc_kwargs = dict(proc_kwargs)
|
||||||
|
child_netns_fd = os.dup(namespace_fd)
|
||||||
|
try:
|
||||||
|
target_stat = os.fstat(child_netns_fd)
|
||||||
|
netns_bootstrap: tuple[int, int, int] = (
|
||||||
|
# FD number retained in the child's descriptor table.
|
||||||
|
child_netns_fd,
|
||||||
|
# Namespace identity checked before the child enters it.
|
||||||
|
target_stat.st_dev,
|
||||||
|
bindspace.ref.inode,
|
||||||
|
)
|
||||||
|
spawn_cmd.extend((
|
||||||
|
'--netns_bootstrap',
|
||||||
|
str(netns_bootstrap),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Keep every descriptor requested by the caller and append
|
||||||
|
# the namespace FD needed during child bootstrap.
|
||||||
|
proc_kwargs['pass_fds'] = (
|
||||||
|
*inherited_fds,
|
||||||
|
child_netns_fd,
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
os.close(child_netns_fd)
|
||||||
|
raise
|
||||||
|
|
||||||
cancelled_during_spawn: bool = False
|
cancelled_during_spawn: bool = False
|
||||||
proc: trio.Process|None = None
|
proc: trio.Process|None = None
|
||||||
ipc_server: _server.Server = actor_nursery._actor.ipc_server
|
ipc_server: _server.Server = actor_nursery._actor.ipc_server
|
||||||
|
peer_event: trio.Event|None = None
|
||||||
|
child_registered: bool = False
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
proc: trio.Process = await trio.lowlevel.open_process(spawn_cmd, **proc_kwargs)
|
try:
|
||||||
|
proc: trio.Process = await trio.lowlevel.open_process(
|
||||||
|
spawn_cmd,
|
||||||
|
**proc_kwargs,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if child_netns_fd is not None:
|
||||||
|
# The child now has its own descriptor-table entry.
|
||||||
|
# Close the temporary entry in the parent process.
|
||||||
|
os.close(child_netns_fd)
|
||||||
log.runtime(
|
log.runtime(
|
||||||
f'Started new child subproc\n'
|
f'Started new child subproc\n'
|
||||||
f'(>\n'
|
f'(>\n'
|
||||||
f' |_{proc}\n'
|
f' |_{proc}\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# `ActorNursery.cancel()` may inspect this event as soon
|
||||||
|
# as the provisional child is published below. Register
|
||||||
|
# the event synchronously before
|
||||||
|
# `wait_for_peer_or_proc_death()` opens its nursery and
|
||||||
|
# checkpoints.
|
||||||
|
peer_event = ipc_server._peer_connected.setdefault(
|
||||||
|
subactor.aid.uid,
|
||||||
|
trio.Event(),
|
||||||
|
)
|
||||||
|
|
||||||
# No `Portal` exists until the IPC handshake returns
|
# No `Portal` exists until the IPC handshake returns
|
||||||
# `chan`. Replace this provisional entry with
|
# `chan`. Replace this provisional entry with
|
||||||
# `Portal(chan)` below.
|
# `Portal(chan)` below.
|
||||||
|
|
@ -141,6 +203,7 @@ async def trio_proc(
|
||||||
proc=proc,
|
proc=proc,
|
||||||
portal=None,
|
portal=None,
|
||||||
)
|
)
|
||||||
|
child_registered = True
|
||||||
if cancel_during_registration:
|
if cancel_during_registration:
|
||||||
cancelled_during_spawn = True
|
cancelled_during_spawn = True
|
||||||
proc.kill()
|
proc.kill()
|
||||||
|
|
@ -152,8 +215,11 @@ async def trio_proc(
|
||||||
# wait for actor to spawn and connect back to us
|
# wait for actor to spawn and connect back to us
|
||||||
# channel should have handshake completed by the
|
# channel should have handshake completed by the
|
||||||
# local actor by the time we get a ref to it
|
# local actor by the time we get a ref to it
|
||||||
event, chan = await ipc_server.wait_for_peer(
|
event, chan = await wait_for_peer_or_proc_death(
|
||||||
subactor.aid.uid
|
ipc_server=ipc_server,
|
||||||
|
uid=subactor.aid.uid,
|
||||||
|
proc_wait=proc.wait,
|
||||||
|
proc_repr=proc,
|
||||||
)
|
)
|
||||||
|
|
||||||
except trio.Cancelled:
|
except trio.Cancelled:
|
||||||
|
|
@ -269,21 +335,21 @@ async def trio_proc(
|
||||||
# to hold off on relaying SIGINT until that child
|
# to hold off on relaying SIGINT until that child
|
||||||
# is complete.
|
# is complete.
|
||||||
# https://github.com/goodboy/tractor/issues/320
|
# https://github.com/goodboy/tractor/issues/320
|
||||||
# -[ ] we need to handle non-root parent-actors specially
|
# -[ ] we need to handle non-root parent-actors
|
||||||
# by somehow determining if a child is in debug and then
|
# specially by somehow determining if a child is in
|
||||||
# avoiding cancel/kill of said child by this
|
# debug and then avoiding cancel/kill of said child
|
||||||
# (intermediary) parent until such a time as the root says
|
# by this (intermediary) parent until such a time as
|
||||||
# the pdb lock is released and we are good to tear down
|
# the root says the pdb lock is released and we are
|
||||||
# (our children)..
|
# good to tear down (our children)..
|
||||||
#
|
#
|
||||||
# -[ ] so maybe something like this where we try to
|
# -[ ] so maybe something like this where we try to
|
||||||
# acquire the lock and get notified of who has it,
|
# acquire the lock and get notified of who has
|
||||||
# check that uid against our known children?
|
# it, check that uid against our known children?
|
||||||
# this_uid: tuple[str, str] = current_actor().uid
|
# this_uid: tuple[str, str] = current_actor().uid
|
||||||
# await debug.acquire_debug_lock(this_uid)
|
# await debug.acquire_debug_lock(this_uid)
|
||||||
|
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
log.cancel(f"Attempting to hard kill {proc}")
|
log.cancel(f'Attempting to hard kill {proc}')
|
||||||
await hard_kill(
|
await hard_kill(
|
||||||
proc,
|
proc,
|
||||||
# NOTE, pass through so post-SIGKILL we
|
# NOTE, pass through so post-SIGKILL we
|
||||||
|
|
@ -302,11 +368,24 @@ async def trio_proc(
|
||||||
subactor=subactor,
|
subactor=subactor,
|
||||||
)
|
)
|
||||||
|
|
||||||
log.debug(f"Joined {proc}")
|
log.debug(f'Joined {proc}')
|
||||||
else:
|
else:
|
||||||
log.warning('Nursery cancelled before sub-proc started')
|
log.warning('Nursery cancelled before sub-proc started')
|
||||||
|
|
||||||
if not cancelled_during_spawn:
|
if (
|
||||||
|
peer_event is not None
|
||||||
|
and
|
||||||
|
ipc_server._peer_connected.get(
|
||||||
|
subactor.aid.uid,
|
||||||
|
) is peer_event
|
||||||
|
):
|
||||||
|
ipc_server._peer_connected.pop(subactor.aid.uid)
|
||||||
|
|
||||||
|
if (
|
||||||
|
child_registered
|
||||||
|
and
|
||||||
|
not cancelled_during_spawn
|
||||||
|
):
|
||||||
# pop child entry to indicate we no longer managing this
|
# pop child entry to indicate we no longer managing this
|
||||||
# subactor
|
# subactor
|
||||||
actor_nursery._children.pop(subactor.aid.uid)
|
actor_nursery._children.pop(subactor.aid.uid)
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ from ..runtime._supervise import (
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..discovery._addr import UnwrappedAddress
|
from ..discovery._addr import UnwrappedAddress
|
||||||
|
from ..net._bindspace import Bindspace
|
||||||
from ..runtime._portal import Portal
|
from ..runtime._portal import Portal
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -254,6 +255,7 @@ async def run(
|
||||||
# is provided.
|
# is provided.
|
||||||
name: str|None = None,
|
name: str|None = None,
|
||||||
bind_addrs: list[UnwrappedAddress]|None = None,
|
bind_addrs: list[UnwrappedAddress]|None = None,
|
||||||
|
bindspace: Bindspace|None = None,
|
||||||
enable_modules: list[str]|None = None,
|
enable_modules: list[str]|None = None,
|
||||||
loglevel: str|None = None,
|
loglevel: str|None = None,
|
||||||
debug_mode: bool|None = None,
|
debug_mode: bool|None = None,
|
||||||
|
|
@ -337,6 +339,7 @@ async def run(
|
||||||
(enable_modules or [])
|
(enable_modules or [])
|
||||||
),
|
),
|
||||||
bind_addrs=bind_addrs,
|
bind_addrs=bind_addrs,
|
||||||
|
bindspace=bindspace,
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
debug_mode=debug_mode,
|
debug_mode=debug_mode,
|
||||||
infect_asyncio=infect_asyncio,
|
infect_asyncio=infect_asyncio,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue