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
parent
3e5a699df6
commit
793ba9f94c
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
---------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue