Scope `open_root_actor()` to a `Bindspace`

Enter a realized netns before root registry, IPC and runtime
startup, then restore the calling thread before owned bindspace
teardown.

Deats,
- duplicate the namespace FD so caller ownership stays intact
- preserve primary body errors across restore and close failures
- reject persistent `mp_forkserver` roots with stale netns risk
- cover cancellation, real netns entry, UDS RPC and public WG
  composition

Prompt-IO: ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
wkt/wg_pyroute2_read
Gud Boi 2026-08-31 16:55:58 -04:00
parent 3e5a699df6
commit 793ba9f94c
9 changed files with 969 additions and 83 deletions

View File

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

View File

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

View File

@ -32,6 +32,39 @@ Bindspaces
.. 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
---------------------

View File

@ -164,9 +164,23 @@ Four corrections, all from
(1:1 proto-key↔type) and `_addr_to_transport` wants a
`MsgTransport` per addr-type, which `wg` doesn't have.
## next
## root composition
The `TunnelledAddress`, native maddr parser, bindspace lifecycle, and
explicit pyroute2 verification APIs live in `tractor.net`. Root actor
bindspace integration remains future work; callers compose these
lifecycles explicitly for now.
explicit pyroute2 verification APIs live in `tractor.net`. Keep the
owning bindspace context outside the root actor so its namespace FD
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.

View File

@ -8,11 +8,13 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager as acm
import os
from pathlib import Path
import sys
from typing import BinaryIO
import pytest
import trio
import tractor
from tractor.net import (
Bindspace,
BindspaceRef,
@ -376,3 +378,99 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
'wg-outer-exit',
'bindspace-exit',
]
@pytest.mark.skipif(
sys.platform != 'linux',
reason='network namespaces are Linux-only',
)
def test_public_wg_bindspace_scopes_root_actor(
monkeypatch: pytest.MonkeyPatch,
tpt_proto: str,
) -> None:
'''
Public network contexts must fully enclose the root runtime.
Attach the real current netns through `tractor.net`, fake only WG
interface provisioning, and open a real root actor with the yielded
`Bindspace`. The trace and inode checks prove interface setup wraps
actor startup, the runtime occupies the realized bindspace, and root
restoration finishes 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(
bindspace=bindspace,
enable_transports=[tpt_proto],
):
events.append('root-open')
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',
]

View File

@ -12,6 +12,7 @@ from typing import (
get_type_hints,
)
import tractor
from tractor.discovery import (
_addr,
_multiaddr,
@ -280,4 +281,7 @@ def test_lazy_annotation_names_resolve():
assert get_type_hints(_addr.Address.get_random)[
'current_actor'
] is Any
assert get_type_hints(tractor.open_root_actor)[
'bindspace'
] == Any|None
assert _addr.__annotations__['_address_types'].startswith('dict')

View File

@ -22,7 +22,10 @@ import pytest
import trio
import tractor
from tractor import _child
from tractor import (
_child,
_root,
)
from tractor.devx import _proctitle
from tractor.net._bindspace import (
Bindspace,
@ -40,7 +43,7 @@ from tractor.spawn import (
from tractor.trionics import patches
_SELF_NETNS_PATH = Path('/proc/self/ns/net')
_SELF_NETNS_PATH = Path('/proc/thread-self/ns/net')
_SELF_FD_DIR = Path('/proc/self/fd')
@ -108,6 +111,87 @@ def _bindspace_for_fd(namespace_fd: int) -> Bindspace:
)
def _run_in_unshared_netns(
test_name: str,
reexec_var: str,
) -> bool:
'''
Re-exec one E2E test with disposable user and net namespaces.
Return `True` in the outer pytest process after nested pytest
succeeds. Return `False` inside that nested process so the caller
performs the privileged namespace transitions itself.
'''
if os.environ.get(reexec_var) == '1':
return False
unshare_path: str|None = shutil.which('unshare')
if unshare_path is None:
pytest.skip('`unshare` is unavailable')
# Give nested pytest `CAP_SYS_ADMIN` only inside a disposable
# user namespace. Probe separately so hosts disabling
# unprivileged user namespaces skip cleanly.
probe = subprocess.run(
[
unshare_path,
'--user',
'--map-root-user',
'--net',
'true',
],
capture_output=True,
text=True,
check=False,
)
if probe.returncode:
reason: str = probe.stderr.strip()
pytest.skip(
f'unprivileged user/net namespaces unavailable: '
f'{reason}'
)
nested_env: dict[str, str] = dict(os.environ)
nested_env[reexec_var] = '1'
nested_env['VIRTUAL_ENV'] = sys.prefix
nested_rt_dir: Path = Path(
tempfile.mkdtemp(prefix='tne-')
)
nested_env['XDG_RUNTIME_DIR'] = str(nested_rt_dir)
python_bin: str = str(Path(sys.executable).parent)
nested_env['PATH'] = (
python_bin
+ os.pathsep
+ nested_env['PATH']
)
test_id: str = f'tests/test_netns_spawn.py::{test_name}'
try:
subprocess.run(
[
unshare_path,
'--user',
'--map-root-user',
'--net',
sys.executable,
'-m',
'pytest',
test_id,
'--spawn-backend=trio',
'--tpt-proto=uds',
'-x',
'--tb=short',
'--no-header',
'--timeout=30',
],
env=nested_env,
check=True,
)
finally:
shutil.rmtree(nested_rt_dir)
return True
class _MockIpcServer:
'''
Provide the peer-event state used by `trio_proc()` tests.
@ -211,6 +295,339 @@ async def _report_child_netns(
os.close(child_netns_fd)
def test_root_netns_same_namespace_skips_setns(
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Root entry into the current netns must not need privilege.
Pin the real current namespace and arm `enter_netns()` as a
failure sentinel. The context validates through a duplicate,
yield the current inode without calling `setns()`, preserve the
source capability, and close the duplicates on normal exit.
'''
namespace_fd: int = os.open(
_SELF_NETNS_PATH,
os.O_RDONLY,
)
bindspace: Bindspace = _bindspace_for_fd(namespace_fd)
initial_fds: set[int] = _fds_referencing(namespace_fd)
def fail_enter_netns(
inherited_fd: int,
inode: int,
) -> int:
'''
Reject a privileged transition for the already-current netns.
'''
raise AssertionError('same-netns entry called `setns()`')
monkeypatch.setattr(_netns, 'enter_netns', fail_enter_netns)
try:
with _netns._enter_netns_temporarily(
bindspace,
) as entered_inode:
assert entered_inode == bindspace.ref.inode
assert os.fstat(namespace_fd).st_ino == entered_inode
# The target duplicate and original-netns snapshot both
# reference the already-current namespace.
assert len(_fds_referencing(namespace_fd)) == (
len(initial_fds) + 2
)
assert _fds_referencing(namespace_fd) == initial_fds
finally:
os.close(namespace_fd)
def test_root_netns_restores_after_body_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
A root-body failure must restore netns before it escapes.
Use distinct regular files as deterministic namespace stand-ins,
replace only `enter_netns()`, and raise a unique error from the
context body. The recorded transitions prove target entry then
original restoration. FD snapshots prove neither temporary handle
leaks, while the caller-owned target FD remains live.
'''
original_path: Path = tmp_path / 'original-netns'
target_path: Path = tmp_path / 'target-netns'
original_path.touch()
target_path.touch()
namespace_fd: int = os.open(target_path, os.O_RDONLY)
bindspace: Bindspace = _bindspace_for_fd(namespace_fd)
initial_fds: set[int] = _fds_referencing(namespace_fd)
transitions: list[int] = []
body_error = RuntimeError('root body failed')
def fake_enter_netns(
inherited_fd: int,
inode: int,
) -> int:
'''
Record each verified target or restoration descriptor.
'''
assert os.fstat(inherited_fd).st_ino == inode
transitions.append(inode)
return inode
monkeypatch.setattr(_netns, '_SELF_NETNS', original_path)
monkeypatch.setattr(_netns, 'enter_netns', fake_enter_netns)
try:
with pytest.raises(RuntimeError) as exc_info:
with _netns._enter_netns_temporarily(bindspace):
raise body_error
assert exc_info.value is body_error
# The fake records target entry before the body, then original
# restoration during context exit.
assert transitions == [
bindspace.ref.inode,
original_path.stat().st_ino,
]
assert _fds_referencing(namespace_fd) == initial_fds
assert os.fstat(namespace_fd).st_ino == bindspace.ref.inode
finally:
os.close(namespace_fd)
def test_root_netns_restores_on_trio_cancellation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Trio cancellation must not interrupt root-netns restoration.
Use deterministic namespace stand-ins and cancel the task inside
`_enter_root_bindspace()` immediately before an explicit Trio
checkpoint. The enclosing `CancelScope` catches cancellation only
after async-context exit. Two recorded sync transitions and exact
FD state then prove restoration and close completed first.
'''
original_path: Path = tmp_path / 'cancel-original-netns'
target_path: Path = tmp_path / 'cancel-target-netns'
original_path.touch()
target_path.touch()
namespace_fd: int = os.open(target_path, os.O_RDONLY)
bindspace: Bindspace = _bindspace_for_fd(namespace_fd)
initial_fds: set[int] = _fds_referencing(namespace_fd)
transitions: list[int] = []
def fake_enter_netns(
inherited_fd: int,
inode: int,
) -> int:
'''
Record target entry and original-netns restoration.
'''
assert os.fstat(inherited_fd).st_ino == inode
transitions.append(inode)
return inode
monkeypatch.setattr(_netns, '_SELF_NETNS', original_path)
monkeypatch.setattr(_netns, 'enter_netns', fake_enter_netns)
async def main() -> None:
'''
Deliver cancellation at a checkpoint inside the netns scope.
'''
with trio.CancelScope() as cancel_scope:
async with _root._enter_root_bindspace(bindspace):
cancel_scope.cancel()
await trio.lowlevel.checkpoint()
assert cancel_scope.cancelled_caught
try:
trio.run(main)
# Target entry is recorded first; original-netns restoration
# is recorded when `_enter_root_bindspace()` exits.
assert transitions == [
bindspace.ref.inode,
original_path.stat().st_ino,
]
assert _fds_referencing(namespace_fd) == initial_fds
assert os.fstat(namespace_fd).st_ino == bindspace.ref.inode
finally:
os.close(namespace_fd)
@pytest.mark.parametrize('body_fails', (False, True))
def test_root_netns_restore_error_precedence(
body_fails: bool,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Netns restoration failure must not hide a root-body failure.
Model target entry as successful and fail the second transition,
which is restoration. The normal-body case must propagate that
restoration error. The failing-body case must instead preserve
its unique error and attach restoration failure as a note. In both
schedules an exact target-FD snapshot proves cleanup still closes
the context's duplicates.
'''
original_path: Path = tmp_path / 'failed-restore-original'
target_path: Path = tmp_path / 'failed-restore-target'
original_path.touch()
target_path.touch()
namespace_fd: int = os.open(target_path, os.O_RDONLY)
bindspace: Bindspace = _bindspace_for_fd(namespace_fd)
initial_fds: set[int] = _fds_referencing(namespace_fd)
body_error = RuntimeError('root body failed first')
restore_error = RuntimeError('root netns restore failed')
transitions: int = 0
def fail_restore(
inherited_fd: int,
inode: int,
) -> int:
'''
Enter the target once, then fail original-netns restoration.
'''
nonlocal transitions
assert os.fstat(inherited_fd).st_ino == inode
transitions += 1
if transitions == 2:
raise restore_error
return inode
monkeypatch.setattr(_netns, '_SELF_NETNS', original_path)
monkeypatch.setattr(_netns, 'enter_netns', fail_restore)
expected_error: RuntimeError = (
body_error
if body_fails
else restore_error
)
try:
with pytest.raises(RuntimeError) as exc_info:
with _netns._enter_netns_temporarily(bindspace):
if body_fails:
raise body_error
assert exc_info.value is expected_error
assert transitions == 2
if body_fails:
assert body_error.__notes__
assert 'restore the original' in body_error.__notes__[0]
assert repr(restore_error) in body_error.__notes__[0]
assert _fds_referencing(namespace_fd) == initial_fds
finally:
os.close(namespace_fd)
def test_root_netns_requires_live_bindspace_fd() -> None:
'''
Root entry cannot use `BindspaceRef.inode` without a live FD.
Construct a valid ref-only `Bindspace` and enter the real root
namespace scope directly. The concrete live-FD error must occur
before namespace capture, probes, sockets, or actor runtime work.
'''
key: str = 'missing-root-netns'
bindspace = Bindspace(
spec=BindspaceSpec(
kind='netns',
key=key,
),
ref=BindspaceRef(
kind='netns',
key=key,
inode=1,
),
# A stored inode cannot authorize namespace entry.
namespace_fd=None,
ownership='borrowed',
)
with pytest.raises(
ValueError,
match='bindspace.namespace_fd.*live netns FD',
):
# Scope entry must reject the missing live handle.
with _netns._enter_netns_temporarily(bindspace):
pytest.fail('root scope accepted a ref-only bindspace')
def test_root_netns_rejects_closed_bindspace_fd() -> None:
'''
A stale integer is not a live root-netns capability.
Construct a `Bindspace` while its real current-netns FD is open,
close that caller-owned descriptor, then attempt root entry. The
concrete live-FD error proves `os.dup()` validates the descriptor
at entry time instead of trusting construction-time metadata.
'''
namespace_fd: int = os.open(
_SELF_NETNS_PATH,
os.O_RDONLY,
)
bindspace: Bindspace = _bindspace_for_fd(namespace_fd)
os.close(namespace_fd)
with pytest.raises(
ValueError,
match='bindspace.namespace_fd.*live FD',
):
with _netns._enter_netns_temporarily(bindspace):
pytest.fail('root scope accepted a closed bindspace FD')
def test_bound_root_rejects_persistent_forkserver(
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
A persistent multiprocessing forkserver process can retain a
previous root's netns.
Select `mp_forkserver` before opening a later bound root, modeling
reuse of the forkserver process which `multiprocessing` creates
once and uses for later child starts. The root API must reject that
backend before namespace entry or runtime startup, preventing
default children from silently inheriting its stale namespace.
'''
namespace_fd: int = os.open(
_SELF_NETNS_PATH,
os.O_RDONLY,
)
bindspace: Bindspace = _bindspace_for_fd(namespace_fd)
monkeypatch.setattr(
_spawn,
'_spawn_method',
'mp_forkserver',
)
async def main() -> None:
'''
Reject the unsafe backend at root-context entry.
'''
with pytest.raises(
NotImplementedError,
match='persistent forkserver',
):
async with tractor.open_root_actor(
bindspace=bindspace,
):
pytest.fail('bound root started under mp_forkserver')
try:
trio.run(main)
assert os.fstat(namespace_fd).st_ino == bindspace.ref.inode
finally:
os.close(namespace_fd)
def test_enter_netns_rejects_mismatched_inherited_fd(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@ -252,7 +669,7 @@ def test_enter_netns_verifies_post_entry_inode(
Successful `setns()` is insufficient without post-entry proof.
Use a real inherited FD and fake only the privileged syscall and
`/proc/self/ns/net` observation. The recorded calls prove both
`/proc/thread-self/ns/net` observation. The calls prove both
hooks execute and `CLONE_NEWNET` constrains the namespace type;
the returned inode proves bootstrap observed the expected netns.
@ -300,7 +717,7 @@ def test_enter_netns_rejects_wrong_post_entry_namespace(
Bootstrap must stop when the process lands in an unexpected netns.
Let the inherited FD check and fake syscall succeed, then report a
different `/proc/self/ns/net` inode. The post-entry guard must raise
different `/proc/thread-self/ns/net` inode. The guard must raise
instead of allowing actor runtime sockets to start in the wrong
namespace.
@ -562,76 +979,12 @@ def test_trio_spawn_relays_bindspace_to_child_actor(
if start_method != 'trio':
pytest.skip('bindspace FD relay is implemented by Trio spawn')
reexec_var: str = 'TRACTOR_TEST_NETNS_E2E_REEXEC'
if os.environ.get(reexec_var) != '1':
unshare_path: str|None = shutil.which('unshare')
if unshare_path is None:
pytest.skip('`unshare` is unavailable')
# Give nested pytest `CAP_SYS_ADMIN` only inside a disposable
# user namespace. The first `--net` creates the target netns;
# the nested test retains its FD before creating a second netns
# for the parent and child to inherit at spawn. Probe separately
# so hosts disabling unprivileged user namespaces skip cleanly.
probe = subprocess.run(
[
unshare_path,
'--user',
'--map-root-user',
'--net',
'true',
],
capture_output=True,
text=True,
check=False,
)
if probe.returncode:
reason: str = probe.stderr.strip()
pytest.skip(
f'unprivileged user/net namespaces unavailable: '
f'{reason}'
)
nested_env: dict[str, str] = dict(os.environ)
nested_env[reexec_var] = '1'
nested_env['VIRTUAL_ENV'] = sys.prefix
nested_rt_dir: Path = Path(
tempfile.mkdtemp(prefix='tne-')
)
nested_env['XDG_RUNTIME_DIR'] = str(nested_rt_dir)
python_bin: str = str(Path(sys.executable).parent)
nested_env['PATH'] = (
python_bin
+ os.pathsep
+ nested_env['PATH']
)
test_id: str = (
'tests/test_netns_spawn.py::'
if _run_in_unshared_netns(
test_name=(
'test_trio_spawn_relays_bindspace_to_child_actor'
)
try:
subprocess.run(
[
unshare_path,
'--user',
'--map-root-user',
'--net',
sys.executable,
'-m',
'pytest',
test_id,
'--spawn-backend=trio',
'--tpt-proto=uds',
'-x',
'--tb=short',
'--no-header',
'--timeout=30',
],
env=nested_env,
check=True,
)
finally:
shutil.rmtree(nested_rt_dir)
),
reexec_var='TRACTOR_TEST_NETNS_E2E_REEXEC',
):
return
assert start_method == 'trio'
@ -708,6 +1061,74 @@ def test_trio_spawn_relays_bindspace_to_child_actor(
os.close(inherited_fd)
def test_root_actor_enters_and_restores_bindspace(
tpt_proto: str,
) -> None:
'''
`open_root_actor()` must enter its supplied networking bindspace.
Re-exec under an unprivileged user/net namespace, retain that
first netns as the target, then move nested pytest into a second.
A real UDS root actor must run its body in the target inode and
keep the source capability open. After full actor teardown, exact
FD and inode assertions prove its duplicate did not leak and the
caller thread returned to the second/original netns.
'''
if _run_in_unshared_netns(
test_name='test_root_actor_enters_and_restores_bindspace',
reexec_var='TRACTOR_TEST_ROOT_NETNS_E2E_REEXEC',
):
return
assert tpt_proto == 'uds'
target_netns_fd: int = os.open(
_SELF_NETNS_PATH,
os.O_RDONLY,
)
target_netns_inode: int = os.fstat(target_netns_fd).st_ino
reffed_tgt_fds: set[int] = _fds_referencing(
target_netns_fd,
)
bindspace: Bindspace = _bindspace_for_fd(target_netns_fd)
# Pin the first disposable netns through `target_netns_fd`, then
# move the caller into a distinct second netns. This gives the root
# one real target to enter and one real caller netns to restore.
os.unshare(os.CLONE_NEWNET)
original_netns_fd: int = os.open(
_SELF_NETNS_PATH,
os.O_RDONLY,
)
original_netns_inode: int = os.fstat(original_netns_fd).st_ino
assert original_netns_inode != target_netns_inode
async def main() -> None:
'''
Inspect the real root runtime inside the target netns.
'''
async with tractor.open_root_actor(
bindspace=bindspace,
enable_transports=['uds'],
):
body_netns_inode: int = _SELF_NETNS_PATH.stat().st_ino
assert body_netns_inode == target_netns_inode
assert os.fstat(target_netns_fd).st_ino == (
target_netns_inode
)
try:
trio.run(main)
assert _SELF_NETNS_PATH.stat().st_ino == original_netns_inode
assert _fds_referencing(
target_netns_fd,
) == reffed_tgt_fds
assert os.fstat(target_netns_fd).st_ino == target_netns_inode
finally:
os.close(original_netns_fd)
os.close(target_netns_fd)
def test_trio_spawn_failure_closes_child_netns_fd_in_parent(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,

View File

@ -18,6 +18,9 @@
Root actor runtime ignition(s).
'''
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import (
asynccontextmanager as acm,
)
@ -31,6 +34,7 @@ import sys
from typing import (
Any,
Callable,
TYPE_CHECKING,
)
import warnings
@ -63,6 +67,11 @@ from ._exceptions import (
RuntimeFailure,
)
if TYPE_CHECKING:
from .net._bindspace import Bindspace
else:
Bindspace = Any
logger = log.get_logger('tractor')
@ -153,9 +162,30 @@ async def maybe_block_bp(
os.environ.pop('PYTHONBREAKPOINT', None)
@acm
async def _enter_root_bindspace(
bindspace: Bindspace|None,
) -> AsyncIterator[None]:
'''
Adapt synchronous root netns entry to the outer async lifecycle.
The wrapped context has no checkpoints, so `trio` cancellation
cannot interrupt thread-local namespace restoration.
'''
from .spawn._netns import _enter_netns_temporarily
with _enter_netns_temporarily(bindspace):
yield
@acm
async def open_root_actor(
*,
# Low-level realized scope. A future tunnelled-address bootstrap
# may open and supply this capability internally.
bindspace: Bindspace|None = None,
tpt_bind_addrs: list[
Address # `Address.get_random()` case
|UnwrappedAddress # registrar case `= uw_reg_addrs`
@ -219,6 +249,10 @@ async def open_root_actor(
All (disjoint) actor-process-trees-as-programs are created via
this entrypoint.
When `bindspace` is provided, enter its network namespace before
any registry or IPC activity and restore the calling thread's
original namespace after complete actor teardown.
'''
# XXX NEVER allow nested actor-trees!
if already_actor := _state.current_actor(
@ -239,10 +273,29 @@ async def open_root_actor(
f'_registry_addrs: {registry_addrs!r}\n'
)
effective_start_method: str = (
os.environ.get('TRACTOR_SPAWN_METHOD')
or start_method
or _spawn._spawn_method
)
if (
bindspace is not None
and
effective_start_method == 'mp_forkserver'
):
raise NotImplementedError(
'Root actor bindspaces are not supported by the '
'`mp_forkserver` spawn backend because a persistent '
'forkserver may retain its original network namespace!'
)
# debug.mk_pdb().set_trace()
async with maybe_block_bp(
debug_mode=debug_mode,
maybe_enable_greenback=maybe_enable_greenback,
async with (
_enter_root_bindspace(bindspace),
maybe_block_bp(
debug_mode=debug_mode,
maybe_enable_greenback=maybe_enable_greenback,
),
):
if enable_transports is None:
enable_transports: list[str] = _state.current_ipc_protos()

View File

@ -20,13 +20,29 @@ Linux network-namespace actor-bootstrap primitives.
'''
from __future__ import annotations
from collections.abc import Callable
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
_SELF_NETNS: Path = Path('/proc/self/ns/net')
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(
@ -36,7 +52,7 @@ def enter_netns(
'''
Enter and verify one inherited Linux network namespace.
The future spawn-bootstrap caller owns and closes `namespace_fd`.
The caller owns and closes `namespace_fd`.
'''
if sys.platform != 'linux':
@ -102,3 +118,167 @@ def enter_netns(
)
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.
`_root._enter_root_bindspace()` adapts this synchronous scope to
the root actor's async lifecycle.
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|None = bindspace.namespace_fd
if namespace_fd is None:
raise ValueError(
'`bindspace.namespace_fd` must be a live netns FD for '
'root actor entry!'
)
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_inode,
)
yield tgt_inode
except BaseException as primary_error:
if restore_needed:
try:
enter_netns(
orig_fd,
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_inode,
)
except BaseException as restore_error:
restore_error.add_note(
'Failed to restore the original network '
'namespace during root netns cleanup.'
)
raise restore_error