diff --git a/ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.md b/ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.md new file mode 100644 index 00000000..f5be10c4 --- /dev/null +++ b/ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.md @@ -0,0 +1,38 @@ +--- +model: gpt-5.6-sol +service: opencode +session: wg-netns-bootstrap-entry +timestamp: 2026-08-28T01:22:05Z +git_ref: 9ae7cd86 +scope: code +substantive: true +raw_file: 20260828T012205Z_9ae7cd86_prompt_io.raw.md +--- + +## Prompt + +After completing and auditing the stacked rebase, the human approved +the next WireGuard Layer C slice: integrate the verified network +namespace entry primitive into child spawn bootstrap. + +## Response summary + +Wire an optional child-owned namespace FD and expected inode into both +child entrypoints. Keep namespace entry and FD closure synchronous and +ordered before actor runtime initialization, while deliberately +deferring backend FD transport and public API plumbing. + +## Files changed + +- `tractor/spawn/_entry.py` - enter and release an optional inherited + namespace capability before multiprocessing child runtime startup. +- `tractor/_child.py` - consume the capability at the start of the + Trio child bootloader, before patching or actor construction. +- `tests/test_netns_spawn.py` - prove entry ordering, failure isolation, + and exact FD ownership for both child entrypoints. + +## Human edits + +The human selected spawn-time namespace integration as the next work +slice and authorized implementation. No direct manual source edits +were observed during generation. diff --git a/ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.raw.md b/ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.raw.md new file mode 100644 index 00000000..00715585 --- /dev/null +++ b/ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.raw.md @@ -0,0 +1,36 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-28T01:22:05Z +git_ref: 9ae7cd86 +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - enter inherited netns during child bootstrap + +The human asked to continue the WireGuard Layer C implementation after +the rebased branch landed the verified `spawn._netns.enter_netns()` +primitive. + +## Generated code + +> `git diff HEAD~1..HEAD -- tractor/_child.py tractor/spawn/_entry.py tests/test_netns_spawn.py` + +Add one optional atomic `(namespace_fd, expected_inode)` bootstrap +capability to the multiprocessing entrypoint and Trio child bootloader. +Enter and verify the namespace before Trio patching, actor construction, +runtime state, logging, Trio startup, parent connection, sockets, or +worker threads, then close the child-owned inherited FD before +continuing. + +Add privilege-free direct-entrypoint tests using real stand-in FDs and +a fake namespace syscall boundary. Cover helper-level no-op behavior, +successful entry ordering, exact FD closure, malformed-FD isolation, +primary-error preservation, and failure cleanup for both child +bootstrap paths. + +## Scope boundary + +This increment does not transfer FDs through spawn backends or expose a +public actor API. Backend-specific FD duplication, bootstrap failure +reporting, and root-process namespace entry remain follow-up work. diff --git a/tests/test_netns_spawn.py b/tests/test_netns_spawn.py index 839236c6..61eb6fc6 100644 --- a/tests/test_netns_spawn.py +++ b/tests/test_netns_spawn.py @@ -4,13 +4,37 @@ Pre-runtime Linux network-namespace entry validation. ''' from __future__ import annotations +import errno +from functools import partial +import os from pathlib import Path from types import SimpleNamespace -from typing import BinaryIO +from typing import ( + Any, + BinaryIO, +) import pytest -from tractor.spawn import _netns +from tractor import _child +from tractor.devx import _proctitle +from tractor.spawn import ( + _entry, + _netns, + _spawn, +) +from tractor.trionics import patches + + +def _assert_fd_closed(namespace_fd: int) -> None: + ''' + Assert that bootstrap consumed its child-owned descriptor. + + ''' + with pytest.raises(OSError) as exc_info: + os.fstat(namespace_fd) + + assert exc_info.value.errno == errno.EBADF def test_enter_netns_rejects_mismatched_inherited_fd( @@ -140,3 +164,416 @@ def test_enter_netns_rejects_wrong_post_entry_namespace( # Deliberately differ from `fake_stat()`'s inode + 1. inode, ) + + +def test_empty_netns_bootstrap_is_a_noop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Ordinary child startup must not attempt namespace entry. + + Leave the optional capability unset and arm `enter_netns()` as a + failure sentinel. The bootstrap boundary must return without any + syscall or descriptor ownership work for existing spawn callers. + + ''' + def fail_enter_netns(namespace_fd: int, inode: int) -> int: + ''' + Reject namespace entry without an explicit capability. + + ''' + raise AssertionError('empty bootstrap attempted netns entry') + + monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns) + + assert _entry._consume_netns_bootstrap(None) is None + + +@pytest.mark.parametrize('namespace_fd', (-1, True, '1')) +def test_invalid_netns_fd_is_never_closed( + namespace_fd: object, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Invalid descriptor values must not reach the OS close boundary. + + Feed negative, boolean, and non-integer values through the atomic + capability. Preserve the namespace primitive's validation error + without letting `bool` alias stdout or allowing cleanup to mask the + primary failure. + + ''' + entry_error = ValueError('invalid netns capability') + + def fail_enter_netns(namespace_fd: int, inode: int) -> int: + ''' + Raise the primary namespace bootstrap error. + + ''' + raise entry_error + + def fail_close(inherited_fd: int) -> None: + ''' + Reject cleanup for a value that cannot be an owned FD. + + ''' + raise AssertionError('invalid namespace FD reached close') + + monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns) + monkeypatch.setattr( + _entry, + 'os', + SimpleNamespace(close=fail_close), + ) + + with pytest.raises(ValueError) as exc_info: + _entry._consume_netns_bootstrap( + (namespace_fd, 1), # type: ignore[arg-type] + ) + + assert exc_info.value is entry_error + + +def test_netns_entry_error_survives_close_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Descriptor cleanup must not mask the primary bootstrap failure. + + Raise a unique entry error for an oversized positive integer whose + cleanup also raises `OverflowError`. The entry error must escape + with cleanup context attached instead of being replaced by the + close failure. + + ''' + namespace_fd: int = 1 << 100 + entry_error = ValueError('invalid netns capability') + + def fail_enter_netns(inherited_fd: int, inode: int) -> int: + ''' + Raise the primary namespace bootstrap error. + + ''' + assert inherited_fd == namespace_fd + raise entry_error + + monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns) + + with pytest.raises(ValueError) as exc_info: + _entry._consume_netns_bootstrap((namespace_fd, 1)) + + assert exc_info.value is entry_error + assert entry_error.__notes__ + assert 'close inherited namespace FD' in entry_error.__notes__[0] + assert 'OverflowError' in entry_error.__notes__[0] + + +@pytest.mark.parametrize('backend', ('mp', 'trio')) +def test_child_entry_consumes_netns_before_runtime( + backend: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Child bootstrap must enter its netns before runtime side effects. + + Give each child entrypoint an exclusively owned stand-in FD. Fake + only namespace entry and every later bootstrap boundary, requiring + the FD to remain open during entry but be closed before actor state, + logging, multiprocessing setup, frame hiding, or `trio.run()`. + This proves verified entry and capability release are one + synchronous prefix of both child startup paths. + + ''' + token_path: Path = tmp_path / f'{backend}-netns' + token_path.touch() + namespace_fd: int = os.open(token_path, os.O_RDONLY) + expected_inode: int = os.fstat(namespace_fd).st_ino + events: list[str] = [] + + def fake_enter_netns( + inherited_fd: int, + inode: int, + ) -> int: + ''' + Record verified entry while the capability remains open. + + ''' + assert os.fstat(inherited_fd).st_ino == expected_inode + assert inherited_fd == namespace_fd + assert inode == expected_inode + events.append('enter-netns') + return inode + + def record( + event: str, + *args: object, + **kwargs: object, + ) -> None: + ''' + Record one post-entry operation after proving FD release. + + ''' + _assert_fd_closed(namespace_fd) + events.append(event) + + class ActorSpy: + ''' + Record multiprocessing actor-state initialization. + + ''' + loglevel = None + uid = ('netns-child', 'test') + _infected_aio = False + + def __setattr__( + self, + name: str, + value: object, + ) -> None: + ''' + Observe the first multiprocessing entrypoint mutation. + + ''' + if name == '_forkserver_info': + record('forkserver-info') + object.__setattr__(self, name, value) + + class StateSpy: + ''' + Record actor publication into runtime-global state. + + ''' + def __setattr__( + self, + name: str, + value: object, + ) -> None: + ''' + Observe `_state._current_actor` publication. + + ''' + record('runtime-state') + object.__setattr__(self, name, value) + + def fake_current_process() -> str: + ''' + Return one display value for multiprocessing startup logging. + + ''' + return 'fake-child-process' + + def fake_start_method(start_method: str) -> SimpleNamespace: + ''' + Record multiprocessing setup after namespace entry. + + ''' + record('start-method') + return SimpleNamespace( + current_process=fake_current_process, + ) + + def fake_actor(**kwargs: object) -> ActorSpy: + ''' + Record Trio child actor construction after namespace entry. + + ''' + record('actor-construction') + return ActorSpy() + + monkeypatch.setattr(_entry, 'enter_netns', fake_enter_netns) + monkeypatch.setattr(_entry, '_state', StateSpy()) + monkeypatch.setattr( + _entry._frame_stack, + 'hide_runtime_frames', + partial(record, 'hide-frames'), + ) + monkeypatch.setattr( + _entry.trio, + 'run', + partial(record, 'trio-run'), + ) + monkeypatch.setattr( + _entry, + 'log', + SimpleNamespace( + info=partial(record, 'log'), + cancel=partial(record, 'log'), + error=partial(record, 'log'), + ), + ) + monkeypatch.setattr( + _spawn, + 'try_set_start_method', + fake_start_method, + ) + monkeypatch.setattr( + patches, + 'apply_all', + partial(record, 'trio-patches'), + ) + monkeypatch.setattr(_child, 'Actor', fake_actor) + monkeypatch.setattr( + _proctitle, + 'set_actor_proctitle', + partial(record, 'proctitle'), + ) + monkeypatch.setattr( + _child, + '_trio_main', + partial(record, 'trio-main'), + ) + + actor: Any = ActorSpy() + bootstrap: tuple[int, int] = ( + namespace_fd, + expected_inode, + ) + if backend == 'mp': + _entry._mp_main( + actor, + [], + (None, None, None, None, None), + 'mp_spawn', + netns_bootstrap=bootstrap, + ) + first_runtime_event: str = 'forkserver-info' + terminal_event: str = 'trio-run' + else: + _child._actor_child_main( + uid=actor.uid, + loglevel=actor.loglevel, + parent_addr=None, + infect_asyncio=False, + netns_bootstrap=bootstrap, + ) + first_runtime_event = 'trio-patches' + terminal_event = 'trio-main' + + assert events[:2] == [ + 'enter-netns', + first_runtime_event, + ] + assert events.count(terminal_event) == 1 + _assert_fd_closed(namespace_fd) + + +@pytest.mark.parametrize('backend', ('mp', 'trio')) +def test_child_entry_failure_closes_netns_fd_before_runtime( + backend: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Failed namespace entry must close its FD and abort child startup. + + Raise a unique error from the namespace boundary while a real + stand-in FD is open. Arm each entrypoint's first later operation as + a failure sentinel, then prove the original error escapes, the + descriptor is closed, and no actor, multiprocessing, frame, or + Trio runtime initialization begins. + + ''' + token_path: Path = tmp_path / f'{backend}-failed-netns' + token_path.touch() + namespace_fd: int = os.open(token_path, os.O_RDONLY) + expected_inode: int = os.fstat(namespace_fd).st_ino + entry_error = RuntimeError('netns entry failed') + events: list[str] = [] + + def fail_enter_netns( + inherited_fd: int, + inode: int, + ) -> int: + ''' + Fail entry while proving the child-owned FD is still open. + + ''' + assert os.fstat(inherited_fd).st_ino == expected_inode + assert inherited_fd == namespace_fd + assert inode == expected_inode + events.append('enter-netns') + raise entry_error + + def fail_after_entry(*args: object, **kwargs: object) -> None: + ''' + Reject any runtime operation after failed namespace entry. + + ''' + raise AssertionError('child runtime started after netns failure') + + class ActorSpy: + ''' + Reject multiprocessing actor-state initialization. + + ''' + loglevel = None + uid = ('failed-netns-child', 'test') + + def __setattr__( + self, + name: str, + value: object, + ) -> None: + ''' + Reject the first multiprocessing entrypoint mutation. + + ''' + if name == '_forkserver_info': + fail_after_entry() + object.__setattr__(self, name, value) + + monkeypatch.setattr(_entry, 'enter_netns', fail_enter_netns) + monkeypatch.setattr( + _entry._frame_stack, + 'hide_runtime_frames', + fail_after_entry, + ) + monkeypatch.setattr( + _spawn, + 'try_set_start_method', + fail_after_entry, + ) + monkeypatch.setattr( + patches, + 'apply_all', + fail_after_entry, + ) + monkeypatch.setattr(_child, 'Actor', fail_after_entry) + monkeypatch.setattr( + _proctitle, + 'set_actor_proctitle', + fail_after_entry, + ) + monkeypatch.setattr( + _child, + '_trio_main', + fail_after_entry, + ) + + actor: Any = ActorSpy() + bootstrap: tuple[int, int] = ( + namespace_fd, + expected_inode, + ) + with pytest.raises(RuntimeError) as exc_info: + if backend == 'mp': + _entry._mp_main( + actor, + [], + (None, None, None, None, None), + 'mp_spawn', + netns_bootstrap=bootstrap, + ) + else: + _child._actor_child_main( + uid=actor.uid, + loglevel=actor.loglevel, + parent_addr=None, + infect_asyncio=False, + netns_bootstrap=bootstrap, + ) + + assert exc_info.value is entry_error + assert events == ['enter-netns'] + _assert_fd_closed(namespace_fd) diff --git a/tractor/_child.py b/tractor/_child.py index a5bd346f..d356f33d 100644 --- a/tractor/_child.py +++ b/tractor/_child.py @@ -26,7 +26,10 @@ from ast import literal_eval from typing import TYPE_CHECKING from .runtime._runtime import Actor -from .spawn._entry import _trio_main +from .spawn._entry import ( + _consume_netns_bootstrap, + _trio_main, +) if TYPE_CHECKING: from .discovery._addr import UnwrappedAddress @@ -52,6 +55,7 @@ def _actor_child_main( parent_addr: UnwrappedAddress | None, infect_asyncio: bool, spawn_method: SpawnMethodKey = 'trio', + netns_bootstrap: tuple[int, int]|None = None, ) -> None: ''' @@ -62,7 +66,13 @@ def _actor_child_main( invokes this from inside a fresh `concurrent.interpreters` sub-interpreter via `Interpreter.call()`. + Consume `netns_bootstrap` before Trio patching, actor construction, + process-title setup, or actor-runtime entry. The spawn backend must + supply an exclusively child-owned FD duplicate. + ''' + _consume_netns_bootstrap(netns_bootstrap) + # Apply defensive monkey-patches for upstream `trio` # bugs we've encountered while running tractor — see # `tractor.trionics.patches` for the catalog + diff --git a/tractor/spawn/_entry.py b/tractor/spawn/_entry.py index 83f1d3ab..0ef7aceb 100644 --- a/tractor/spawn/_entry.py +++ b/tractor/spawn/_entry.py @@ -21,6 +21,7 @@ Sub-process entry points. from __future__ import annotations from functools import partial import multiprocessing as mp +import os from typing import ( Any, TYPE_CHECKING, @@ -48,6 +49,7 @@ from ..runtime._runtime import ( async_main, Actor, ) +from ._netns import enter_netns if TYPE_CHECKING: from ._spawn import SpawnMethodKey @@ -56,6 +58,54 @@ if TYPE_CHECKING: log = get_logger() +def _consume_netns_bootstrap( + netns_bootstrap: tuple[int, int]|None, +) -> int|None: + ''' + Enter and release one child-owned network namespace capability. + + The FD must be an exclusively child-owned backend duplicate. It is + closed whether namespace entry succeeds or fails, before any actor + runtime setup can continue. + + ''' + if netns_bootstrap is None: + return None + + namespace_fd: int + expected_inode: int + namespace_fd, expected_inode = netns_bootstrap + if ( + type(namespace_fd) is not int + or + namespace_fd < 0 + ): + # Let `enter_netns()` report its precise validation error, but + # never pass bool/non-int/negative values to `os.close()`. + return enter_netns( + namespace_fd, + expected_inode, + ) + + try: + entered_inode: int = enter_netns( + namespace_fd, + expected_inode, + ) + except BaseException as entry_error: + try: + os.close(namespace_fd) + except Exception as close_error: + entry_error.add_note( + f'Also failed to close inherited namespace FD ' + f'{namespace_fd}: {close_error!r}' + ) + raise + else: + os.close(namespace_fd) + return entered_inode + + def _mp_main( actor: Actor, @@ -64,12 +114,18 @@ def _mp_main( start_method: SpawnMethodKey, parent_addr: UnwrappedAddress | None = None, infect_asyncio: bool = False, + netns_bootstrap: tuple[int, int]|None = None, ) -> None: ''' The routine called *after fork* which invokes a fresh `trio.run()` + Consume `netns_bootstrap` before multiprocessing or actor-runtime + setup. The spawn backend must supply a child-owned FD duplicate. + ''' + _consume_netns_bootstrap(netns_bootstrap) + actor._forkserver_info = forkserver_info from ._spawn import try_set_start_method spawn_ctx: mp.context.BaseContext = try_set_start_method(start_method)