Pin child netns before `trio` spawn checkpoints
Pin the calling task's current netns when `bindspace` is omitted on Linux/Trio. Retain the scope through child startup so a reused subprocess worker cannot select a different namespace. Pass the child-owned FD with its expected device and inode. Check both before entry, skip `setns()` for an already-current namespace, and release the transferred FD before runtime setup. Require a live integer FD for every `Bindspace`. Add canonical `parent_addr` and `registry_addrs` spawn inputs. Require an explicit parent to name a live listener and keep each child's registrar selection in its own `SpawnSpec` and runtime snapshot, preserving supervisor and sibling inheritance. Drop async root `bindspace=` entry; retain MP rejection of explicit bindspaces. Cover real UDS cross-netns bootstrap, stale workers, concurrent FD borrowers, registrar isolation, and failed starts. (this patch was generated in some part by `Codex` using `GPT-6` (`openai`))wkt/runtime_net_scopes
parent
d2274d3252
commit
645e25c041
|
|
@ -245,6 +245,8 @@ def test_open_wg_iface_shields_cancelled_cleanup(
|
|||
|
||||
|
||||
def test_open_wg_bindspace_nests_resource_lifetimes(
|
||||
tmp_path: Path,
|
||||
request: pytest.FixtureRequest,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
'''
|
||||
|
|
@ -270,14 +272,16 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
|
|||
bindspace_spec: BindspaceSpec = BindspaceSpec(
|
||||
kind='netns',
|
||||
)
|
||||
token = (tmp_path / 'netns').open('w+b')
|
||||
request.addfinalizer(token.close)
|
||||
bindspace: Bindspace = Bindspace(
|
||||
spec=bindspace_spec,
|
||||
ref=BindspaceRef(
|
||||
kind='netns',
|
||||
key=None,
|
||||
inode=1,
|
||||
inode=os.fstat(token.fileno()).st_ino,
|
||||
),
|
||||
namespace_fd=None,
|
||||
namespace_fd=token.fileno(),
|
||||
ownership='borrowed',
|
||||
)
|
||||
outer_spec: WGTunnelSpec = WGTunnelSpec(
|
||||
|
|
@ -384,18 +388,16 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
|
|||
sys.platform != 'linux',
|
||||
reason='network namespaces are Linux-only',
|
||||
)
|
||||
def test_public_wg_bindspace_scopes_root_actor(
|
||||
def test_public_wg_bindspace_scopes_child_actor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tpt_proto: str,
|
||||
) -> None:
|
||||
'''
|
||||
Public network contexts must fully enclose the root runtime.
|
||||
Public network contexts must fully enclose supervised children.
|
||||
|
||||
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.
|
||||
interface provisioning, and supervise a child in the yielded
|
||||
`Bindspace`. The nursery reaps it before network-resource teardown.
|
||||
|
||||
'''
|
||||
events: list[str] = []
|
||||
|
|
@ -450,10 +452,18 @@ def test_public_wg_bindspace_scopes_root_actor(
|
|||
) as bindspace:
|
||||
events.append('bindspace-open')
|
||||
async with tractor.open_root_actor(
|
||||
bindspace=bindspace,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import trio
|
|||
import tractor
|
||||
from tractor import (
|
||||
_child,
|
||||
_root,
|
||||
)
|
||||
from tractor.devx import _proctitle
|
||||
from tractor.net._bindspace import (
|
||||
|
|
@ -185,6 +184,7 @@ def _run_in_unshared_netns(
|
|||
'-x',
|
||||
'--tb=short',
|
||||
'--no-header',
|
||||
'--show-capture=all',
|
||||
'--timeout=30',
|
||||
],
|
||||
env=nested_env,
|
||||
|
|
@ -220,7 +220,7 @@ class _MockIpcServer:
|
|||
|
||||
def _netns_bootstrap_from_cmd(
|
||||
command: list[str],
|
||||
) -> tuple[int, int]:
|
||||
) -> tuple[int, int, int]:
|
||||
'''
|
||||
Parse the namespace tuple that the exec child would receive.
|
||||
|
||||
|
|
@ -320,6 +320,7 @@ def test_root_netns_same_namespace_skips_setns(
|
|||
|
||||
def fail_enter_netns(
|
||||
inherited_fd: int,
|
||||
device: int,
|
||||
inode: int,
|
||||
) -> int:
|
||||
'''
|
||||
|
|
@ -372,6 +373,7 @@ def test_root_netns_restores_after_body_error(
|
|||
|
||||
def fake_enter_netns(
|
||||
inherited_fd: int,
|
||||
device: int,
|
||||
inode: int,
|
||||
) -> int:
|
||||
'''
|
||||
|
|
@ -411,10 +413,10 @@ def test_root_netns_restores_on_trio_cancellation(
|
|||
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
|
||||
`_enter_netns_temporarily()` 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.
|
||||
after the fake synchronous scope exits. Two recorded transitions
|
||||
and exact FD state prove restoration and close completed first.
|
||||
'''
|
||||
original_path: Path = tmp_path / 'cancel-original-netns'
|
||||
target_path: Path = tmp_path / 'cancel-target-netns'
|
||||
|
|
@ -427,6 +429,7 @@ def test_root_netns_restores_on_trio_cancellation(
|
|||
|
||||
def fake_enter_netns(
|
||||
inherited_fd: int,
|
||||
device: int,
|
||||
inode: int,
|
||||
) -> int:
|
||||
'''
|
||||
|
|
@ -446,7 +449,7 @@ def test_root_netns_restores_on_trio_cancellation(
|
|||
|
||||
'''
|
||||
with trio.CancelScope() as cancel_scope:
|
||||
async with _root._enter_root_bindspace(bindspace):
|
||||
with _netns._enter_netns_temporarily(bindspace):
|
||||
cancel_scope.cancel()
|
||||
await trio.lowlevel.checkpoint()
|
||||
|
||||
|
|
@ -456,6 +459,7 @@ def test_root_netns_restores_on_trio_cancellation(
|
|||
trio.run(main)
|
||||
# Target entry is recorded first; original-netns restoration
|
||||
# is recorded when `_enter_root_bindspace()` exits.
|
||||
# The removed adapter is now exercised through its sync primitive.
|
||||
assert transitions == [
|
||||
bindspace.ref.inode,
|
||||
original_path.stat().st_ino,
|
||||
|
|
@ -496,6 +500,7 @@ def test_root_netns_restore_error_precedence(
|
|||
|
||||
def fail_restore(
|
||||
inherited_fd: int,
|
||||
device: int,
|
||||
inode: int,
|
||||
) -> int:
|
||||
'''
|
||||
|
|
@ -538,12 +543,12 @@ 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.
|
||||
Reject a ref-only `Bindspace` at construction, before namespace
|
||||
capture, probes, sockets, or actor runtime work.
|
||||
'''
|
||||
key: str = 'missing-root-netns'
|
||||
bindspace = Bindspace(
|
||||
with pytest.raises(ValueError, match='namespace_fd.*non-negative'):
|
||||
Bindspace(
|
||||
spec=BindspaceSpec(
|
||||
kind='netns',
|
||||
key=key,
|
||||
|
|
@ -557,14 +562,7 @@ def test_root_netns_requires_live_bindspace_fd() -> None:
|
|||
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')
|
||||
|
||||
|
||||
@_linux_netns_only
|
||||
|
|
@ -624,8 +622,8 @@ def test_bound_root_rejects_persistent_forkserver(
|
|||
|
||||
'''
|
||||
with pytest.raises(
|
||||
NotImplementedError,
|
||||
match='persistent forkserver',
|
||||
TypeError,
|
||||
match='unexpected keyword argument.*bindspace',
|
||||
):
|
||||
async with tractor.open_root_actor(
|
||||
bindspace=bindspace,
|
||||
|
|
@ -668,6 +666,7 @@ def test_enter_netns_rejects_mismatched_inherited_fd(
|
|||
):
|
||||
_netns.enter_netns(
|
||||
namespace_file.fileno(),
|
||||
os.fstat(namespace_file.fileno()).st_dev,
|
||||
# Deliberately differ from `token_path`'s inode.
|
||||
inode + 1,
|
||||
)
|
||||
|
|
@ -697,7 +696,10 @@ def test_enter_netns_verifies_post_entry_inode(
|
|||
|
||||
def fake_stat(path: Path) -> SimpleNamespace:
|
||||
stat_calls.append(path)
|
||||
return SimpleNamespace(st_ino=inode)
|
||||
return SimpleNamespace(
|
||||
st_dev=os.fstat(namespace_fd).st_dev,
|
||||
st_ino=inode if len(stat_calls) > 1 else inode + 1,
|
||||
)
|
||||
|
||||
namespace_file: BinaryIO
|
||||
with token_path.open('rb') as namespace_file:
|
||||
|
|
@ -712,13 +714,14 @@ def test_enter_netns_verifies_post_entry_inode(
|
|||
|
||||
entered_inode: int = _netns.enter_netns(
|
||||
namespace_fd,
|
||||
os.fstat(namespace_fd).st_dev,
|
||||
inode,
|
||||
)
|
||||
|
||||
assert setns_calls == [
|
||||
(namespace_fd, _netns.os.CLONE_NEWNET),
|
||||
]
|
||||
assert stat_calls == [_netns._SELF_NETNS]
|
||||
assert stat_calls == [_netns._SELF_NETNS, _netns._SELF_NETNS]
|
||||
assert entered_inode == inode
|
||||
|
||||
|
||||
|
|
@ -753,7 +756,10 @@ def test_enter_netns_rejects_wrong_post_entry_namespace(
|
|||
inode: int = token_path.stat().st_ino
|
||||
|
||||
def fake_stat(path: Path) -> SimpleNamespace:
|
||||
return SimpleNamespace(st_ino=inode + 1)
|
||||
return SimpleNamespace(
|
||||
st_dev=os.fstat(namespace_file.fileno()).st_dev,
|
||||
st_ino=inode + 1,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
type(_netns._SELF_NETNS),
|
||||
|
|
@ -766,6 +772,7 @@ def test_enter_netns_rejects_wrong_post_entry_namespace(
|
|||
):
|
||||
_netns.enter_netns(
|
||||
namespace_file.fileno(),
|
||||
os.fstat(namespace_file.fileno()).st_dev,
|
||||
# Deliberately differ from `fake_stat()`'s inode + 1.
|
||||
inode,
|
||||
)
|
||||
|
|
@ -782,7 +789,7 @@ def test_empty_netns_bootstrap_is_a_noop(
|
|||
syscall or descriptor ownership work for existing spawn callers.
|
||||
|
||||
'''
|
||||
def fail_enter_netns(namespace_fd: int, inode: int) -> int:
|
||||
def fail_enter_netns(namespace_fd: int, device: int, inode: int) -> int:
|
||||
'''
|
||||
Reject namespace entry without an explicit capability.
|
||||
|
||||
|
|
@ -810,7 +817,7 @@ def test_invalid_netns_fd_is_never_closed(
|
|||
'''
|
||||
entry_error = ValueError('invalid netns capability')
|
||||
|
||||
def fail_enter_netns(namespace_fd: int, inode: int) -> int:
|
||||
def fail_enter_netns(namespace_fd: int, device: int, inode: int) -> int:
|
||||
'''
|
||||
Raise the primary namespace bootstrap error.
|
||||
|
||||
|
|
@ -833,7 +840,7 @@ def test_invalid_netns_fd_is_never_closed(
|
|||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_entry._consume_netns_bootstrap(
|
||||
(namespace_fd, 1), # type: ignore[arg-type]
|
||||
(namespace_fd, 0, 1), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert exc_info.value is entry_error
|
||||
|
|
@ -854,7 +861,7 @@ def test_netns_entry_error_survives_close_failure(
|
|||
namespace_fd: int = 1 << 100
|
||||
entry_error = ValueError('invalid netns capability')
|
||||
|
||||
def fail_enter_netns(inherited_fd: int, inode: int) -> int:
|
||||
def fail_enter_netns(inherited_fd: int, device: int, inode: int) -> int:
|
||||
'''
|
||||
Raise the primary namespace bootstrap error.
|
||||
|
||||
|
|
@ -865,7 +872,7 @@ def test_netns_entry_error_survives_close_failure(
|
|||
monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_entry._consume_netns_bootstrap((namespace_fd, 1))
|
||||
_entry._consume_netns_bootstrap((namespace_fd, 0, 1))
|
||||
|
||||
assert exc_info.value is entry_error
|
||||
assert entry_error.__notes__
|
||||
|
|
@ -888,7 +895,7 @@ def test_trio_child_cli_forwards_netns_bootstrap(
|
|||
calls: list[dict[str, object]] = []
|
||||
uid: tuple[str, str] = ('cli-netns-child', 'test')
|
||||
parent_addr: tuple[str, int] = ('127.0.0.1', 1616)
|
||||
bootstrap: tuple[int, int] = (12, 3456)
|
||||
bootstrap: tuple[int, int, int] = (12, 7, 3456)
|
||||
|
||||
def fake_actor_child_main(**kwargs: object) -> None:
|
||||
'''
|
||||
|
|
@ -926,14 +933,14 @@ def test_trio_spawn_requires_live_bindspace_fd() -> None:
|
|||
'''
|
||||
A `BindspaceRef` alone cannot let a child enter its namespace.
|
||||
|
||||
Construct a valid `Bindspace` with its required identity metadata
|
||||
but no open namespace FD. Calling `trio_proc()` must fail before
|
||||
`open_process()` because an inode identifies a namespace but does
|
||||
not provide an open handle that the child can inherit.
|
||||
Attempt construction with identity metadata but no open namespace
|
||||
FD. An inode identifies a namespace; construction must reject the
|
||||
missing capability before the spawn backend can receive it.
|
||||
|
||||
'''
|
||||
key: str = 'missing-spawn-netns'
|
||||
bindspace = Bindspace(
|
||||
with pytest.raises(ValueError, match='namespace_fd.*non-negative'):
|
||||
Bindspace(
|
||||
spec=BindspaceSpec(
|
||||
kind='netns',
|
||||
key=key,
|
||||
|
|
@ -948,29 +955,6 @@ def test_trio_spawn_requires_live_bindspace_fd() -> None:
|
|||
namespace_fd=None,
|
||||
ownership='borrowed',
|
||||
)
|
||||
uid: tuple[str, str] = ('missing-netns-fd', 'test')
|
||||
|
||||
async def main() -> None:
|
||||
'''
|
||||
Reject the ref-only capability before `open_process()`.
|
||||
|
||||
'''
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match='bindspace.namespace_fd.*required',
|
||||
):
|
||||
await _trio.trio_proc(
|
||||
name=uid[0],
|
||||
actor_nursery=_SpawnTestNursery(),
|
||||
subactor=_spawn_test_subactor(uid),
|
||||
errors={},
|
||||
bind_addrs=[],
|
||||
parent_addr=('127.0.0.1', 1616),
|
||||
_runtime_vars={},
|
||||
bindspace=bindspace,
|
||||
)
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
def test_trio_spawn_relays_bindspace_to_child_actor(
|
||||
|
|
@ -1040,6 +1024,8 @@ def test_trio_spawn_relays_bindspace_to_child_actor(
|
|||
async with tractor.open_nursery() as actor_nursery:
|
||||
portal: tractor.Portal = await actor_nursery.start_actor(
|
||||
'netns-bootstrap-child',
|
||||
parent_addr=tractor.current_actor().accept_addr,
|
||||
registry_addrs=tractor.current_actor().reg_addrs,
|
||||
bindspace=bindspace,
|
||||
enable_modules=[__name__],
|
||||
proc_kwargs={
|
||||
|
|
@ -1075,72 +1061,14 @@ 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:
|
||||
@pytest.mark.parametrize('backend', ('trio', 'mp_spawn', 'mp_forkserver'))
|
||||
def test_root_actor_rejects_async_bindspace(backend: 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.
|
||||
Root networking cannot be changed around an async runtime scope.
|
||||
|
||||
'''
|
||||
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)
|
||||
with pytest.raises(TypeError, match='unexpected keyword argument.*bindspace'):
|
||||
tractor.open_root_actor(bindspace=None, start_method=backend)
|
||||
|
||||
|
||||
def test_trio_spawn_failure_closes_child_netns_fd_in_parent(
|
||||
|
|
@ -1174,8 +1102,9 @@ def test_trio_spawn_failure_closes_child_netns_fd_in_parent(
|
|||
Fail after checking the child FD in `pass_fds` and the CLI.
|
||||
|
||||
'''
|
||||
child_fd, expected_inode = _netns_bootstrap_from_cmd(command)
|
||||
child_fd, expected_device, expected_inode = _netns_bootstrap_from_cmd(command)
|
||||
assert kwargs['pass_fds'] == (child_fd,)
|
||||
assert os.fstat(child_fd).st_dev == expected_device
|
||||
assert os.fstat(child_fd).st_ino == expected_inode
|
||||
child_fds.append(child_fd)
|
||||
raise open_error
|
||||
|
|
@ -1247,8 +1176,9 @@ def test_trio_spawn_cancel_closes_child_netns_fd_in_parent(
|
|||
Record the child FD, then signal that the open call is parked.
|
||||
|
||||
'''
|
||||
child_fd, expected_inode = _netns_bootstrap_from_cmd(command)
|
||||
child_fd, expected_device, expected_inode = _netns_bootstrap_from_cmd(command)
|
||||
assert kwargs['pass_fds'] == (child_fd,)
|
||||
assert os.fstat(child_fd).st_dev == expected_device
|
||||
assert os.fstat(child_fd).st_ino == expected_inode
|
||||
child_fds.append(child_fd)
|
||||
open_called.set()
|
||||
|
|
@ -1372,6 +1302,7 @@ def test_child_entry_consumes_netns_before_runtime(
|
|||
|
||||
def fake_enter_netns(
|
||||
inherited_fd: int,
|
||||
device: int,
|
||||
inode: int,
|
||||
) -> int:
|
||||
'''
|
||||
|
|
@ -1504,8 +1435,9 @@ def test_child_entry_consumes_netns_before_runtime(
|
|||
)
|
||||
|
||||
actor: Any = ActorSpy()
|
||||
bootstrap: tuple[int, int] = (
|
||||
bootstrap: tuple[int, int, int] = (
|
||||
namespace_fd,
|
||||
os.fstat(namespace_fd).st_dev,
|
||||
expected_inode,
|
||||
)
|
||||
if backend == 'mp':
|
||||
|
|
@ -1562,6 +1494,7 @@ def test_child_entry_failure_closes_netns_fd_before_runtime(
|
|||
|
||||
def fail_enter_netns(
|
||||
inherited_fd: int,
|
||||
device: int,
|
||||
inode: int,
|
||||
) -> int:
|
||||
'''
|
||||
|
|
@ -1631,8 +1564,9 @@ def test_child_entry_failure_closes_netns_fd_before_runtime(
|
|||
)
|
||||
|
||||
actor: Any = ActorSpy()
|
||||
bootstrap: tuple[int, int] = (
|
||||
bootstrap: tuple[int, int, int] = (
|
||||
namespace_fd,
|
||||
os.fstat(namespace_fd).st_dev,
|
||||
expected_inode,
|
||||
)
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
|
|
@ -1656,3 +1590,325 @@ def test_child_entry_failure_closes_netns_fd_before_runtime(
|
|||
assert exc_info.value is entry_error
|
||||
assert events == ['enter-netns']
|
||||
_assert_fd_closed(namespace_fd)
|
||||
|
||||
|
||||
@_linux_netns_only
|
||||
def test_child_same_netns_needs_no_setns(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
'''Validate and consume an already-current namespace without privilege.'''
|
||||
fd = os.open(_SELF_NETNS_PATH, os.O_RDONLY)
|
||||
target = os.fstat(fd)
|
||||
# A Python build without setns support can still inherit its netns.
|
||||
monkeypatch.delattr(_netns.os, 'setns', raising=False)
|
||||
assert _entry._consume_netns_bootstrap(
|
||||
(fd, target.st_dev, target.st_ino),
|
||||
) == target.st_ino
|
||||
_assert_fd_closed(fd)
|
||||
|
||||
|
||||
@_linux_netns_only
|
||||
@pytest.mark.parametrize('field', ('device', 'inode'))
|
||||
def test_child_rejects_identity_mismatch_and_closes_fd(
|
||||
field: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
'''Neither identity component may disagree with the transferred FD.'''
|
||||
fd = os.open(_SELF_NETNS_PATH, os.O_RDONLY)
|
||||
target = os.fstat(fd)
|
||||
|
||||
def fail_setns(*args: object) -> None:
|
||||
pytest.fail('identity mismatch reached setns')
|
||||
|
||||
monkeypatch.setattr(_netns.os, 'setns', fail_setns, raising=False)
|
||||
with pytest.raises(ValueError, match='Inherited namespace FD identity'):
|
||||
_entry._consume_netns_bootstrap((
|
||||
fd,
|
||||
target.st_dev + (field == 'device'),
|
||||
target.st_ino + (field == 'inode'),
|
||||
))
|
||||
_assert_fd_closed(fd)
|
||||
|
||||
|
||||
async def _report_spawn_scope() -> dict:
|
||||
'''Observe actual child runtime state after registration and entry.'''
|
||||
from tractor.runtime import _state
|
||||
|
||||
actor = tractor.current_actor()
|
||||
ns = _SELF_NETNS_PATH.stat()
|
||||
return {
|
||||
'identity': (ns.st_dev, ns.st_ino),
|
||||
'registrars': actor.reg_addrs,
|
||||
'runtime_registrars': _state._runtime_vars['_registry_addrs'],
|
||||
'parent': actor._parent_chan.raddr.unwrap(),
|
||||
'source': tractor.__file__,
|
||||
}
|
||||
|
||||
|
||||
@_linux_netns_only
|
||||
def test_concurrent_children_share_fd_and_isolate_registrars(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
'''Real children borrow one capability with independent spawn routing.'''
|
||||
from tractor.runtime import _state
|
||||
|
||||
async def main() -> None:
|
||||
with _netns.pin_current_netns() as bindspace:
|
||||
fd = bindspace.namespace_fd
|
||||
initial_fds = _fds_referencing(fd)
|
||||
target = os.fstat(fd)
|
||||
addresses = [('unix', str(tmp_path / f'r{i}.sock')) for i in range(3)]
|
||||
async with tractor.open_root_actor(
|
||||
registry_addrs=addresses,
|
||||
enable_transports=['uds'],
|
||||
start_method='trio',
|
||||
) as actor:
|
||||
original_regs = list(actor.reg_addrs)
|
||||
original_runtime = list(_state._runtime_vars['_registry_addrs'])
|
||||
original_regs_obj = actor.reg_addrs
|
||||
original_runtime_obj = _state._runtime_vars['_registry_addrs']
|
||||
selected_parent = next(
|
||||
addr for addr in actor.accept_addrs
|
||||
if addr != actor.accept_addr
|
||||
)
|
||||
selections = [[addresses[0]], [addresses[1], addresses[2]]]
|
||||
reports = []
|
||||
caller_tasks = []
|
||||
pin_tasks = []
|
||||
real_open = os.open
|
||||
|
||||
def record_open(path, flags, *args, **kwargs):
|
||||
result = real_open(path, flags, *args, **kwargs)
|
||||
if path == _netns._SELF_NETNS:
|
||||
pin_tasks.append(trio.lowlevel.current_task())
|
||||
return result
|
||||
|
||||
async with tractor.open_nursery() as nursery:
|
||||
async def start(index: int) -> None:
|
||||
portal = await nursery.start_actor(
|
||||
f'shared-{index}',
|
||||
bindspace=bindspace,
|
||||
parent_addr=selected_parent,
|
||||
registry_addrs=selections[index],
|
||||
enable_modules=[__name__],
|
||||
)
|
||||
report = await portal.run(_report_spawn_scope)
|
||||
assert list(map(tuple, report['registrars'])) == selections[index]
|
||||
assert list(map(tuple, report['runtime_registrars'])) == selections[index]
|
||||
assert tuple(report['parent']) == selected_parent
|
||||
assert tuple(report['identity']) == (target.st_dev, target.st_ino)
|
||||
assert report['source'] == tractor.__file__
|
||||
reports.append(report)
|
||||
|
||||
async with trio.open_nursery() as tn:
|
||||
for index in range(2):
|
||||
tn.start_soon(start, index)
|
||||
assert len(reports) == 2
|
||||
assert actor.reg_addrs is original_regs_obj
|
||||
assert actor.reg_addrs == original_regs
|
||||
assert _state._runtime_vars['_registry_addrs'] is original_runtime_obj
|
||||
assert _state._runtime_vars['_registry_addrs'] == original_runtime
|
||||
assert _fds_referencing(fd) == initial_fds
|
||||
|
||||
# Default inheritance pins on this task before dispatch.
|
||||
monkeypatch.setattr(_netns.os, 'open', record_open)
|
||||
caller_tasks.append(trio.lowlevel.current_task())
|
||||
sibling = await nursery.start_actor(
|
||||
'default-sibling',
|
||||
enable_modules=[__name__],
|
||||
)
|
||||
monkeypatch.setattr(_netns.os, 'open', real_open)
|
||||
assert pin_tasks == caller_tasks
|
||||
report = await sibling.run(_report_spawn_scope)
|
||||
assert list(map(tuple, report['registrars'])) == original_regs
|
||||
assert list(map(tuple, report['runtime_registrars'])) == original_runtime
|
||||
assert tuple(report['parent']) == actor.accept_addr
|
||||
assert tuple(report['identity']) == (target.st_dev, target.st_ino)
|
||||
await nursery.cancel()
|
||||
assert _fds_referencing(fd) == initial_fds
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('option,value', [
|
||||
('parent_addr', ('127.0.0.1', 1616)),
|
||||
('parent_addr', ['unix', '/tmp/legacy.sock']),
|
||||
('parent_addr', ('uds', '/tmp/alias.sock')),
|
||||
('parent_addr', ('unix', '/tmp/not-a-listener.sock')),
|
||||
('registry_addrs', []),
|
||||
('registry_addrs', (('tcp', '127.0.0.1', 1616),)),
|
||||
('registry_addrs', [('127.0.0.1', 1616)]),
|
||||
('registry_addrs', [('uds', '/tmp/alias.sock')]),
|
||||
('registry_addrs', [('tcp', '127.0.0.1', True)]),
|
||||
('registry_addrs', [('tcp', '127.0.0.1', 65536)]),
|
||||
('registry_addrs', [('tcp', 'invalid-host', 1616)]),
|
||||
('registry_addrs', [('unix', '')]),
|
||||
])
|
||||
def test_start_actor_rejects_invalid_routing(option: str, value: object) -> None:
|
||||
'''Invalid routing must fail before spawning or publishing child state.'''
|
||||
from tractor.runtime import _state
|
||||
|
||||
async def main() -> None:
|
||||
async with tractor.open_nursery() as nursery:
|
||||
original = dict(_state._runtime_vars)
|
||||
with pytest.raises(ValueError):
|
||||
await nursery.start_actor('invalid-routing', **{option: value})
|
||||
assert _state._runtime_vars == original
|
||||
assert not nursery._children
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
@_linux_netns_only
|
||||
@pytest.mark.parametrize('cancel', (False, True))
|
||||
def test_default_capture_closes_on_failed_start(
|
||||
cancel: bool,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
'''The caller pin and backend duplicate unwind on failure or cancellation.'''
|
||||
opened = []
|
||||
real_open = os.open
|
||||
opened_process = trio.Event()
|
||||
spawn_error = OSError('exec failed before child creation')
|
||||
|
||||
def record_open(path, flags, *args, **kwargs):
|
||||
result = real_open(path, flags, *args, **kwargs)
|
||||
if path == _netns._SELF_NETNS:
|
||||
opened.append(result)
|
||||
return result
|
||||
|
||||
async def fail_process(command, **kwargs):
|
||||
fd, dev, ino = _netns_bootstrap_from_cmd(command)
|
||||
assert (os.fstat(fd).st_dev, os.fstat(fd).st_ino) == (dev, ino)
|
||||
opened.append(fd)
|
||||
opened_process.set()
|
||||
if cancel:
|
||||
await trio.sleep_forever()
|
||||
raise spawn_error
|
||||
|
||||
async def main() -> None:
|
||||
async with tractor.open_root_actor(start_method='trio'):
|
||||
async with tractor.open_nursery() as nursery:
|
||||
monkeypatch.setattr(_netns.os, 'open', record_open)
|
||||
monkeypatch.setattr(trio.lowlevel, 'open_process', fail_process)
|
||||
if cancel:
|
||||
async with trio.open_nursery() as tn:
|
||||
tn.start_soon(nursery.start_actor, 'cancelled-default')
|
||||
await opened_process.wait()
|
||||
tn.cancel_scope.cancel()
|
||||
else:
|
||||
with pytest.raises(OSError) as caught:
|
||||
await nursery.start_actor('failed-default')
|
||||
assert caught.value is spawn_error
|
||||
assert not nursery._children
|
||||
assert len(opened) == 2
|
||||
for fd in opened:
|
||||
_assert_fd_closed(fd)
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
def test_default_child_ignores_stale_subprocess_worker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
'''An omitted bindspace follows the calling task, not a reused worker.'''
|
||||
if _run_in_unshared_netns(
|
||||
test_name='test_default_child_ignores_stale_subprocess_worker',
|
||||
reexec_var='TRACTOR_TEST_STALE_WORKER_REEXEC',
|
||||
):
|
||||
return
|
||||
|
||||
target_netns_fd = os.open(_SELF_NETNS_PATH, os.O_RDONLY)
|
||||
target_netns_inode = os.fstat(target_netns_fd).st_ino
|
||||
# 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.
|
||||
# Only the subprocess worker enters that first namespace now.
|
||||
os.unshare(os.CLONE_NEWNET)
|
||||
original_netns_inode = _SELF_NETNS_PATH.stat().st_ino
|
||||
assert original_netns_inode != target_netns_inode
|
||||
worker_inodes = []
|
||||
real_popen = subprocess.Popen
|
||||
|
||||
def observe_popen(*args, **kwargs):
|
||||
worker_inodes.append(_SELF_NETNS_PATH.stat().st_ino)
|
||||
return real_popen(*args, **kwargs)
|
||||
|
||||
async def main() -> None:
|
||||
async with tractor.open_root_actor(
|
||||
enable_transports=['uds'],
|
||||
start_method='trio',
|
||||
):
|
||||
# Reuse the worker after it has legitimately changed its
|
||||
# thread-local netns. No event-loop thread transition occurs.
|
||||
await trio.to_thread.run_sync(
|
||||
os.setns, target_netns_fd, os.CLONE_NEWNET,
|
||||
)
|
||||
monkeypatch.setattr(subprocess, 'Popen', observe_popen)
|
||||
async with tractor.open_nursery() as nursery:
|
||||
portal = await nursery.start_actor(
|
||||
'inherits-task-netns', enable_modules=[__name__],
|
||||
)
|
||||
report = await portal.run(_report_spawn_scope)
|
||||
assert report['identity'][1] == original_netns_inode
|
||||
assert worker_inodes == [target_netns_inode]
|
||||
assert _SELF_NETNS_PATH.stat().st_ino == original_netns_inode
|
||||
await nursery.cancel()
|
||||
|
||||
try:
|
||||
trio.run(main)
|
||||
assert os.fstat(target_netns_fd).st_ino == target_netns_inode
|
||||
finally:
|
||||
os.close(target_netns_fd)
|
||||
|
||||
|
||||
def test_cross_netns_inherited_loopback_registrar_fails(
|
||||
capfd: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
'''A reachable UDS parent cannot make a host-loopback registrar reachable.'''
|
||||
if _run_in_unshared_netns(
|
||||
test_name='test_cross_netns_inherited_loopback_registrar_fails',
|
||||
reexec_var='TRACTOR_TEST_UNREACHABLE_REG_REEXEC',
|
||||
):
|
||||
return
|
||||
|
||||
ip = shutil.which('ip')
|
||||
if ip is None:
|
||||
pytest.skip('iproute2 ip command is needed to enable disposable loopback')
|
||||
fd = os.open(_SELF_NETNS_PATH, os.O_RDONLY)
|
||||
bindspace = _bindspace_for_fd(fd)
|
||||
os.unshare(os.CLONE_NEWNET)
|
||||
subprocess.run([ip, 'link', 'set', 'lo', 'up'], check=True)
|
||||
|
||||
async def main() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix='tnr-') as directory:
|
||||
parent_addr = ('unix', str(Path(directory) / 'parent.sock'))
|
||||
async with tractor.open_root_actor(
|
||||
registry_addrs=[('tcp', '127.0.0.1', 1616)],
|
||||
tpt_bind_addrs=[parent_addr],
|
||||
enable_transports=['tcp'],
|
||||
start_method='trio',
|
||||
):
|
||||
with trio.fail_after(10):
|
||||
async with tractor.open_nursery() as nursery:
|
||||
portal = await nursery.start_actor(
|
||||
'unreachable-registrar',
|
||||
bindspace=bindspace,
|
||||
parent_addr=parent_addr,
|
||||
)
|
||||
_, proc, _ = nursery._children[portal.channel.aid.uid]
|
||||
# Join the failing runtime: the bootstrap handshake
|
||||
# precedes registrar connection and RPC readiness.
|
||||
|
||||
assert proc.returncode == 1
|
||||
assert not nursery._children
|
||||
assert os.fstat(fd).st_ino == bindspace.ref.inode
|
||||
|
||||
try:
|
||||
trio.run(main)
|
||||
error_output = capfd.readouterr().err
|
||||
assert 'Network is unreachable' in error_output
|
||||
assert '127.0.0.1:1616' in error_output
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
|
|
|||
|
|
@ -49,11 +49,11 @@ def parse_ipaddr(arg):
|
|||
return arg
|
||||
|
||||
|
||||
def parse_netns_bootstrap(arg: str) -> tuple[int, int]:
|
||||
def parse_netns_bootstrap(arg: str) -> tuple[int, int, int]:
|
||||
'''
|
||||
Parse one atomic inherited namespace capability.
|
||||
|
||||
Descriptor and inode validation remains in
|
||||
Descriptor and device/inode validation remains in
|
||||
`_consume_netns_bootstrap()` so every valid descriptor-shaped
|
||||
input reaches its exact child-owned cleanup boundary.
|
||||
|
||||
|
|
@ -62,16 +62,16 @@ def parse_netns_bootstrap(arg: str) -> tuple[int, int]:
|
|||
value: object = literal_eval(arg)
|
||||
except (ValueError, SyntaxError) as exc:
|
||||
raise argparse.ArgumentTypeError(
|
||||
'netns bootstrap must be an `(fd, inode)` tuple'
|
||||
'netns bootstrap must be an `(fd, device, inode)` tuple'
|
||||
) from exc
|
||||
|
||||
if (
|
||||
not isinstance(value, tuple)
|
||||
or
|
||||
len(value) != 2
|
||||
len(value) != 3
|
||||
):
|
||||
raise argparse.ArgumentTypeError(
|
||||
'netns bootstrap must be an `(fd, inode)` tuple'
|
||||
'netns bootstrap must be an `(fd, device, inode)` tuple'
|
||||
)
|
||||
|
||||
return value
|
||||
|
|
@ -83,7 +83,7 @@ def _actor_child_main(
|
|||
parent_addr: UnwrappedAddress | None,
|
||||
infect_asyncio: bool,
|
||||
spawn_method: SpawnMethodKey = 'trio',
|
||||
netns_bootstrap: tuple[int, int]|None = None,
|
||||
netns_bootstrap: tuple[int, int, int]|None = None,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ Root actor runtime ignition(s).
|
|||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import (
|
||||
asynccontextmanager as acm,
|
||||
)
|
||||
|
|
@ -34,7 +33,6 @@ import sys
|
|||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
import warnings
|
||||
|
||||
|
|
@ -70,9 +68,6 @@ from ._exceptions import (
|
|||
RuntimeFailure,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .net._bindspace import Bindspace
|
||||
|
||||
|
||||
logger = log.get_logger('tractor')
|
||||
|
||||
|
|
@ -163,29 +158,13 @@ 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,
|
||||
# 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[
|
||||
Address # concrete transport address case
|
||||
|
|
@ -250,9 +229,9 @@ 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.
|
||||
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!
|
||||
|
|
@ -274,25 +253,8 @@ 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 (
|
||||
_enter_root_bindspace(bindspace),
|
||||
maybe_block_bp(
|
||||
debug_mode=debug_mode,
|
||||
maybe_enable_greenback=maybe_enable_greenback,
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ class Bindspace(
|
|||
'''
|
||||
spec: BindspaceSpec
|
||||
ref: BindspaceRef
|
||||
namespace_fd: int|None
|
||||
namespace_fd: int
|
||||
ownership: BindspaceOwnership
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
|
|
@ -206,7 +206,7 @@ class Bindspace(
|
|||
'''
|
||||
spec: BindspaceSpec = self.spec
|
||||
ref: BindspaceRef = self.ref
|
||||
namespace_fd: int|None = self.namespace_fd
|
||||
namespace_fd: int = self.namespace_fd
|
||||
ownership: BindspaceOwnership = self.ownership
|
||||
|
||||
if spec.kind != ref.kind:
|
||||
|
|
@ -237,14 +237,13 @@ class Bindspace(
|
|||
f'`BindspaceSpec.lifecycle={spec.lifecycle!r}` '
|
||||
f'requires ownership={expected_ownership!r}!'
|
||||
)
|
||||
if namespace_fd is not None:
|
||||
if (
|
||||
type(namespace_fd) is not int
|
||||
or
|
||||
namespace_fd < 0
|
||||
):
|
||||
raise ValueError(
|
||||
'`namespace_fd` must be non-negative or `None`!'
|
||||
'`namespace_fd` must be a non-negative `int`!'
|
||||
)
|
||||
fd_inode: int = os.fstat(namespace_fd).st_ino
|
||||
if ref.inode != fd_inode:
|
||||
|
|
|
|||
|
|
@ -479,7 +479,7 @@ def _sync_create_wg_iface(
|
|||
'`tractor[wg]` extra.'
|
||||
) from exc
|
||||
|
||||
namespace_fd: int|None = bindspace.namespace_fd
|
||||
namespace_fd: int = bindspace.namespace_fd
|
||||
ipr: Any = IPRoute(
|
||||
netns=namespace_fd,
|
||||
flags=0,
|
||||
|
|
|
|||
|
|
@ -18,12 +18,16 @@
|
|||
``trio`` inspired apis and helpers
|
||||
|
||||
"""
|
||||
from contextlib import asynccontextmanager as acm
|
||||
from contextlib import (
|
||||
asynccontextmanager as acm,
|
||||
ExitStack,
|
||||
)
|
||||
from functools import partial
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
import typing
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import trio
|
||||
|
|
@ -36,6 +40,7 @@ from ..devx import (
|
|||
from ..discovery._addr import (
|
||||
UnwrappedAddress,
|
||||
mk_uuid,
|
||||
wrap_address,
|
||||
)
|
||||
from ._state import (
|
||||
current_actor,
|
||||
|
|
@ -74,6 +79,22 @@ if TYPE_CHECKING:
|
|||
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(
|
||||
portal: Portal,
|
||||
# `ProcessType` is `TYPE_CHECKING`-only (defined under that
|
||||
|
|
@ -421,6 +442,8 @@ class ActorNursery:
|
|||
|
||||
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,
|
||||
enable_transports: list[str] = [_state._def_tpt_proto],
|
||||
enable_modules: list[str]|None = None,
|
||||
|
|
@ -435,6 +458,12 @@ class ActorNursery:
|
|||
Start a (daemon) actor: an process that has no designated
|
||||
"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
|
||||
own bootstrap module for the trio spawn backend instead of
|
||||
applying the parent ``__main__`` re-exec fixup during startup.
|
||||
|
|
@ -478,6 +507,27 @@ class ActorNursery:
|
|||
)
|
||||
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(
|
||||
name=name,
|
||||
uuid=mk_uuid(),
|
||||
|
|
@ -488,14 +538,26 @@ class ActorNursery:
|
|||
inherit_parent_main=inherit_parent_main,
|
||||
|
||||
# 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
|
||||
assert parent_addr
|
||||
subactor.reg_addrs = selected_reg_addrs
|
||||
|
||||
# start a task to spawn a process
|
||||
# blocks until process has been started and a portal setup
|
||||
# XXX: the type ignore is actually due to a `mypy` bug
|
||||
with ExitStack() as scope:
|
||||
if (
|
||||
bindspace is None
|
||||
and sys.platform == 'linux'
|
||||
and _spawn._spawn_method == 'trio'
|
||||
):
|
||||
from ..spawn._netns import pin_current_netns
|
||||
|
||||
# Capture on the calling actor task before nursery.start()
|
||||
# checkpoints or any subprocess worker can choose a netns.
|
||||
bindspace = scope.enter_context(pin_current_netns())
|
||||
|
||||
return await self._da_nursery.start( # type: ignore
|
||||
partial(
|
||||
_spawn.new_proc,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ log = get_logger()
|
|||
|
||||
|
||||
def _consume_netns_bootstrap(
|
||||
netns_bootstrap: tuple[int, int]|None,
|
||||
netns_bootstrap: tuple[int, int, int]|None,
|
||||
) -> int|None:
|
||||
'''
|
||||
Enter and release one child-owned network namespace capability.
|
||||
|
|
@ -73,8 +73,9 @@ def _consume_netns_bootstrap(
|
|||
return None
|
||||
|
||||
namespace_fd: int
|
||||
expected_device: int
|
||||
expected_inode: int
|
||||
namespace_fd, expected_inode = netns_bootstrap
|
||||
namespace_fd, expected_device, expected_inode = netns_bootstrap
|
||||
if (
|
||||
type(namespace_fd) is not int
|
||||
or
|
||||
|
|
@ -84,12 +85,14 @@ def _consume_netns_bootstrap(
|
|||
# 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:
|
||||
|
|
@ -114,7 +117,7 @@ def _mp_main(
|
|||
start_method: SpawnMethodKey,
|
||||
parent_addr: UnwrappedAddress | None = None,
|
||||
infect_asyncio: bool = False,
|
||||
netns_bootstrap: tuple[int, int]|None = None,
|
||||
netns_bootstrap: tuple[int, int, int]|None = None,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ _SELF_NETNS: Path = Path('/proc/thread-self/ns/net')
|
|||
|
||||
def enter_netns(
|
||||
namespace_fd: int,
|
||||
expected_device: int,
|
||||
expected_inode: int,
|
||||
) -> int:
|
||||
'''
|
||||
|
|
@ -76,6 +77,22 @@ def enter_netns(
|
|||
'`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',
|
||||
|
|
@ -95,13 +112,6 @@ def enter_netns(
|
|||
'Python has no Linux network namespace entry support!'
|
||||
)
|
||||
|
||||
inherited_inode: int = os.fstat(namespace_fd).st_ino
|
||||
if inherited_inode != expected_inode:
|
||||
raise ValueError(
|
||||
f'Inherited namespace FD inode {inherited_inode} does not '
|
||||
f'match expected inode {expected_inode}!'
|
||||
)
|
||||
|
||||
try:
|
||||
setns(namespace_fd, clone_newnet)
|
||||
except OSError as exc:
|
||||
|
|
@ -110,11 +120,13 @@ def enter_netns(
|
|||
f'{expected_inode}!'
|
||||
) from exc
|
||||
|
||||
entered_inode: int = _SELF_NETNS.stat().st_ino
|
||||
if entered_inode != expected_inode:
|
||||
entered = _SELF_NETNS.stat()
|
||||
entered_inode: int = entered.st_ino
|
||||
if (entered.st_dev, entered_inode) != expected:
|
||||
raise RuntimeError(
|
||||
f'Entered network namespace inode {entered_inode} does not '
|
||||
f'match expected inode {expected_inode}!'
|
||||
f'Entered network namespace identity '
|
||||
f'{(entered.st_dev, entered_inode)} does not '
|
||||
f'match expected identity {expected}!'
|
||||
)
|
||||
|
||||
return entered_inode
|
||||
|
|
@ -181,8 +193,9 @@ def _enter_netns_temporarily(
|
|||
'''
|
||||
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.
|
||||
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
|
||||
|
|
@ -199,12 +212,7 @@ def _enter_netns_temporarily(
|
|||
'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!'
|
||||
)
|
||||
namespace_fd: int = bindspace.namespace_fd
|
||||
if (
|
||||
type(namespace_fd) is not int
|
||||
or
|
||||
|
|
@ -250,6 +258,7 @@ def _enter_netns_temporarily(
|
|||
if restore_needed:
|
||||
enter_netns(
|
||||
tgt_fd,
|
||||
tgt_stat.st_dev,
|
||||
tgt_inode,
|
||||
)
|
||||
|
||||
|
|
@ -260,6 +269,7 @@ def _enter_netns_temporarily(
|
|||
try:
|
||||
enter_netns(
|
||||
orig_fd,
|
||||
orig_stat.st_dev,
|
||||
orig_inode,
|
||||
)
|
||||
except BaseException as restore_error:
|
||||
|
|
@ -274,6 +284,7 @@ def _enter_netns_temporarily(
|
|||
try:
|
||||
enter_netns(
|
||||
orig_fd,
|
||||
orig_stat.st_dev,
|
||||
orig_inode,
|
||||
)
|
||||
except BaseException as restore_error:
|
||||
|
|
@ -282,3 +293,25 @@ def _enter_netns_temporarily(
|
|||
'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',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -124,11 +124,7 @@ async def trio_proc(
|
|||
|
||||
child_netns_fd: int|None = None
|
||||
if bindspace is not None:
|
||||
if (namespace_fd := bindspace.namespace_fd) is None:
|
||||
raise ValueError(
|
||||
'`bindspace.namespace_fd` is required for '
|
||||
'Trio child transport!'
|
||||
)
|
||||
namespace_fd: int = bindspace.namespace_fd
|
||||
|
||||
# Snapshot caller-owned process options before duplicating the
|
||||
# live `Bindspace.namespace_fd`. No checkpoint separates this
|
||||
|
|
@ -139,10 +135,12 @@ async def trio_proc(
|
|||
proc_kwargs = dict(proc_kwargs)
|
||||
child_netns_fd = os.dup(namespace_fd)
|
||||
try:
|
||||
netns_bootstrap: tuple[int, int] = (
|
||||
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((
|
||||
|
|
|
|||
Loading…
Reference in New Issue