From f5301d3fb05c4576fb72bb98343cb7a283445d5e Mon Sep 17 00:00:00 2001 From: mahmoud Date: Mon, 6 Apr 2026 05:51:02 +0000 Subject: [PATCH 01/14] Add per-actor parent-main replay opt-out Let actor callers skip replaying the parent __main__ during child startup so downstream integrations can avoid inheriting incompatible bootstrap state without changing the default spawn behavior. --- tests/test_spawning.py | 47 +++++++++++++++++++++++++++++++++++ tractor/runtime/_supervise.py | 9 +++++++ 2 files changed, 56 insertions(+) diff --git a/tests/test_spawning.py b/tests/test_spawning.py index 283d1785..7dfa51af 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -197,3 +197,50 @@ def test_loglevel_propagated_to_subactor( # ensure subactor spits log message on stderr captured = capfd.readouterr() assert 'yoyoyo' in captured.err + + +def test_start_actor_can_skip_parent_main_replay(monkeypatch, reg_addr): + captured_parent_main_data: list[dict[str, str]] = [] + from tractor.runtime import _supervise as supervise_module + + async def fake_new_proc( + name: str, + actor_nursery, + subactor, + errors, + bind_addrs, + parent_addr, + _runtime_vars, + *, + infect_asyncio: bool = False, + task_status=trio.TASK_STATUS_IGNORED, + proc_kwargs: dict[str, Any] = {}, + ) -> None: + captured_parent_main_data.append(dict(subactor._parent_main_data)) + task_status.started(object()) + + monkeypatch.setattr( + supervise_module._spawn, + 'new_proc', + fake_new_proc, + ) + + async def main() -> None: + async with tractor.open_root_actor( + registry_addrs=[reg_addr], + ): + async with tractor.open_nursery() as an: + await an.start_actor( + 'replaying-parent-main', + enable_modules=[__name__], + ) + await an.start_actor( + 'isolated-parent-main', + enable_modules=[__name__], + replay_parent_main=False, + ) + + trio.run(main) + + assert captured_parent_main_data[0] + assert captured_parent_main_data[1] == {} diff --git a/tractor/runtime/_supervise.py b/tractor/runtime/_supervise.py index 3cd7d4c7..f80d0845 100644 --- a/tractor/runtime/_supervise.py +++ b/tractor/runtime/_supervise.py @@ -194,6 +194,7 @@ class ActorNursery: loglevel: str|None = None, # set log level per subactor debug_mode: bool|None = None, infect_asyncio: bool = False, + replay_parent_main: bool = True, # TODO: ideally we can rm this once we no longer have # a `._ria_nursery` since the dependent APIs have been @@ -206,6 +207,10 @@ class ActorNursery: Start a (daemon) actor: an process that has no designated "main task" besides the runtime. + Pass ``replay_parent_main=False`` to keep this child on its own + bootstrap module instead of re-running the parent's ``__main__`` + during startup. + ''' __runtimeframe__: int = 1 # noqa loglevel: str = ( @@ -246,6 +251,8 @@ class ActorNursery: # verbatim relay this actor's registrar addresses registry_addrs=current_actor().registry_addrs, ) + if not replay_parent_main: + subactor._parent_main_data = {} parent_addr: UnwrappedAddress = self._actor.accept_addr assert parent_addr @@ -289,6 +296,7 @@ class ActorNursery: enable_modules: list[str] | None = None, loglevel: str | None = None, # set log level per subactor infect_asyncio: bool = False, + replay_parent_main: bool = True, proc_kwargs: dict[str, any] = {}, **kwargs, # explicit args to ``fn`` @@ -320,6 +328,7 @@ class ActorNursery: # use the run_in_actor nursery nursery=self._ria_nursery, infect_asyncio=infect_asyncio, + replay_parent_main=replay_parent_main, proc_kwargs=proc_kwargs ) From 6309c2e6fc11cb16f128568e8b2adf3b9e063afb Mon Sep 17 00:00:00 2001 From: mahmoud Date: Mon, 6 Apr 2026 06:19:51 +0000 Subject: [PATCH 02/14] Route parent-main replay through SpawnSpec Keep trio child bootstrap data in the spawn handshake instead of stashing it on Actor state so the replay opt-out stays explicit and avoids stale-looking runtime fields. --- tests/test_spawning.py | 71 +++++++++++++++++------------------ tractor/runtime/_runtime.py | 42 +++++++++++---------- tractor/runtime/_supervise.py | 3 +- tractor/spawn/_spawn.py | 11 +++++- 4 files changed, 68 insertions(+), 59 deletions(-) diff --git a/tests/test_spawning.py b/tests/test_spawning.py index 7dfa51af..be14a825 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -168,6 +168,11 @@ async def check_loglevel(level): log.critical('yoyoyo') +async def get_main_mod_name() -> str: + import sys + return sys.modules['__main__'].__name__ + + def test_loglevel_propagated_to_subactor( start_method, capfd, @@ -199,48 +204,40 @@ def test_loglevel_propagated_to_subactor( assert 'yoyoyo' in captured.err -def test_start_actor_can_skip_parent_main_replay(monkeypatch, reg_addr): - captured_parent_main_data: list[dict[str, str]] = [] - from tractor.runtime import _supervise as supervise_module - - async def fake_new_proc( - name: str, - actor_nursery, - subactor, - errors, - bind_addrs, - parent_addr, - _runtime_vars, - *, - infect_asyncio: bool = False, - task_status=trio.TASK_STATUS_IGNORED, - proc_kwargs: dict[str, Any] = {}, - ) -> None: - captured_parent_main_data.append(dict(subactor._parent_main_data)) - task_status.started(object()) +def test_start_actor_can_skip_parent_main_replay( + start_method, + reg_addr, + monkeypatch, +): + if start_method != 'trio': + pytest.skip( + 'parent main replay opt-out only affects the trio spawn backend' + ) + from tractor.spawn import _mp_fixup_main monkeypatch.setattr( - supervise_module._spawn, - 'new_proc', - fake_new_proc, + _mp_fixup_main, + '_mp_figure_out_main', + lambda: {'init_main_from_name': __name__}, ) async def main() -> None: - async with tractor.open_root_actor( + async with tractor.open_nursery( + name='registrar', + start_method=start_method, registry_addrs=[reg_addr], - ): - async with tractor.open_nursery() as an: - await an.start_actor( - 'replaying-parent-main', - enable_modules=[__name__], - ) - await an.start_actor( - 'isolated-parent-main', - enable_modules=[__name__], - replay_parent_main=False, - ) + ) as an: + replaying = await an.run_in_actor( + get_main_mod_name, + name='replaying-parent-main', + ) + isolated = await an.run_in_actor( + get_main_mod_name, + name='isolated-parent-main', + replay_parent_main=False, + ) + + assert await replaying.result() == '__mp_main__' + assert await isolated.result() == '__main__' trio.run(main) - - assert captured_parent_main_data[0] - assert captured_parent_main_data[1] == {} diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index 0ffc6112..d41e1b0f 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -217,8 +217,6 @@ class Actor: ''' return self._ipc_server - # Information about `__main__` from parent - _parent_main_data: dict[str, str] _parent_chan_cs: CancelScope|None = None _spawn_spec: msgtypes.SpawnSpec|None = None @@ -265,10 +263,6 @@ class Actor: self._cancel_called_by: tuple[str, tuple]|None = None self._cancel_called: bool = False - # retreive and store parent `__main__` data which - # will be passed to children - self._parent_main_data = _mp_fixup_main._mp_figure_out_main() - # TODO? only add this when `is_debug_mode() == True` no? # always include debugging tools module if _state.is_root_process(): @@ -535,6 +529,7 @@ class Actor: def load_modules( self, + parent_main_data: dict[str, str]|None = None, ) -> None: ''' @@ -548,13 +543,20 @@ class Actor: ''' try: if self._spawn_method == 'trio': - parent_data = self._parent_main_data - if 'init_main_from_name' in parent_data: + if ( + parent_main_data is not None + and + 'init_main_from_name' in parent_main_data + ): _mp_fixup_main._fixup_main_from_name( - parent_data['init_main_from_name']) - elif 'init_main_from_path' in parent_data: + parent_main_data['init_main_from_name']) + elif ( + parent_main_data is not None + and + 'init_main_from_path' in parent_main_data + ): _mp_fixup_main._fixup_main_from_path( - parent_data['init_main_from_path']) + parent_main_data['init_main_from_path']) status: str = 'Attempting to import enabled modules:\n' @@ -840,6 +842,7 @@ class Actor: Channel, list[UnwrappedAddress]|None, list[str]|None, # preferred tpts + dict[str, str]|None, ]: ''' Bootstrap this local actor's runtime config from its parent by @@ -860,6 +863,7 @@ class Actor: await chan._do_handshake(aid=self.aid) accept_addrs: list[UnwrappedAddress]|None = None + parent_main_data: dict[str, str]|None = None if self._spawn_method == "trio": @@ -1020,17 +1024,13 @@ class Actor: spawnspec.enable_modules ) - self._parent_main_data = spawnspec._parent_main_data - # XXX QUESTION(s)^^^ - # -[ ] already set in `.__init__()` right, but how is - # it diff from this blatant parent copy? - # -[ ] do we need/want the .__init__() value in - # just the root case orr? + parent_main_data = spawnspec._parent_main_data return ( chan, accept_addrs, - _state._runtime_vars['_enable_tpts'] + _state._runtime_vars['_enable_tpts'], + parent_main_data, ) # failed to connect back? @@ -1523,6 +1523,7 @@ async def async_main( # establish primary connection with immediate parent actor._parent_chan: Channel|None = None + parent_main_data: dict[str, str]|None = None # is this a sub-actor? # get runtime info from parent. @@ -1531,6 +1532,7 @@ async def async_main( actor._parent_chan, set_accept_addr_says_rent, maybe_preferred_transports_says_rent, + parent_main_data, ) = await actor._from_parent(parent_addr) accept_addrs: list[UnwrappedAddress] = [] @@ -1610,7 +1612,9 @@ async def async_main( # XXX: do this **after** establishing a channel to the parent # but **before** starting the message loop for that channel # such that import errors are properly propagated upwards - actor.load_modules() + actor.load_modules( + parent_main_data=parent_main_data, + ) # XXX TODO XXX: figuring out debugging of this # would somemwhat guarantee "self-hosted" runtime diff --git a/tractor/runtime/_supervise.py b/tractor/runtime/_supervise.py index f80d0845..a1ef950c 100644 --- a/tractor/runtime/_supervise.py +++ b/tractor/runtime/_supervise.py @@ -251,8 +251,6 @@ class ActorNursery: # verbatim relay this actor's registrar addresses registry_addrs=current_actor().registry_addrs, ) - if not replay_parent_main: - subactor._parent_main_data = {} parent_addr: UnwrappedAddress = self._actor.accept_addr assert parent_addr @@ -272,6 +270,7 @@ class ActorNursery: parent_addr, _rtv, # run time vars infect_asyncio=infect_asyncio, + replay_parent_main=replay_parent_main, proc_kwargs=proc_kwargs ) ) diff --git a/tractor/spawn/_spawn.py b/tractor/spawn/_spawn.py index 9d89648c..ad270036 100644 --- a/tractor/spawn/_spawn.py +++ b/tractor/spawn/_spawn.py @@ -50,6 +50,7 @@ from tractor.discovery._addr import UnwrappedAddress from tractor.runtime._portal import Portal from tractor.runtime._runtime import Actor from ._entry import _mp_main +from . import _mp_fixup_main from tractor._exceptions import ActorFailure from tractor.msg import ( types as msgtypes, @@ -420,6 +421,7 @@ async def new_proc( *, infect_asyncio: bool = False, + replay_parent_main: bool = True, task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED, proc_kwargs: dict[str, any] = {} @@ -440,6 +442,7 @@ async def new_proc( parent_addr, _runtime_vars, # run time vars infect_asyncio=infect_asyncio, + replay_parent_main=replay_parent_main, task_status=task_status, proc_kwargs=proc_kwargs ) @@ -457,6 +460,7 @@ async def trio_proc( _runtime_vars: dict[str, Any], # serialized and sent to _child *, infect_asyncio: bool = False, + replay_parent_main: bool = True, task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED, proc_kwargs: dict[str, any] = {} @@ -549,7 +553,11 @@ async def trio_proc( # send a "spawning specification" which configures the # initial runtime state of the child. sspec = msgtypes.SpawnSpec( - _parent_main_data=subactor._parent_main_data, + _parent_main_data=( + _mp_fixup_main._mp_figure_out_main() + if replay_parent_main + else {} + ), enable_modules=subactor.enable_modules, reg_addrs=subactor.reg_addrs, bind_addrs=bind_addrs, @@ -680,6 +688,7 @@ async def mp_proc( _runtime_vars: dict[str, Any], # serialized and sent to _child *, infect_asyncio: bool = False, + replay_parent_main: bool = True, task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED, proc_kwargs: dict[str, any] = {} From 83b6c4270a886b040c1eedd0179653042e67f9f2 Mon Sep 17 00:00:00 2001 From: mahmoud Date: Mon, 6 Apr 2026 06:26:17 +0000 Subject: [PATCH 03/14] Simplify parent-main replay opt-out. Keep actor-owned parent-main capture and let `_mp_figure_out_main()` decide whether to return `__main__` bootstrap data, avoiding the extra SpawnSpec plumbing while preserving the per-actor flag. --- tests/test_spawning.py | 6 ++++- tractor/runtime/_runtime.py | 40 ++++++++++++++------------------- tractor/runtime/_supervise.py | 2 +- tractor/spawn/_mp_fixup_main.py | 7 +++++- tractor/spawn/_spawn.py | 11 +-------- 5 files changed, 30 insertions(+), 36 deletions(-) diff --git a/tests/test_spawning.py b/tests/test_spawning.py index be14a825..d61282f5 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -218,7 +218,11 @@ def test_start_actor_can_skip_parent_main_replay( monkeypatch.setattr( _mp_fixup_main, '_mp_figure_out_main', - lambda: {'init_main_from_name': __name__}, + lambda replay_parent_main=True: ( + {'init_main_from_name': __name__} + if replay_parent_main + else {} + ), ) async def main() -> None: diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index d41e1b0f..a9ae0f54 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -217,6 +217,8 @@ class Actor: ''' return self._ipc_server + # Information about `__main__` from parent + _parent_main_data: dict[str, str] _parent_chan_cs: CancelScope|None = None _spawn_spec: msgtypes.SpawnSpec|None = None @@ -242,6 +244,7 @@ class Actor: loglevel: str|None = None, registry_addrs: list[Address]|None = None, spawn_method: str|None = None, + replay_parent_main: bool = True, arbiter_addr: UnwrappedAddress|None = None, @@ -263,6 +266,12 @@ class Actor: self._cancel_called_by: tuple[str, tuple]|None = None self._cancel_called: bool = False + # retrieve and store parent `__main__` data which + # will be passed to children + self._parent_main_data = _mp_fixup_main._mp_figure_out_main( + replay_parent_main, + ) + # TODO? only add this when `is_debug_mode() == True` no? # always include debugging tools module if _state.is_root_process(): @@ -529,7 +538,6 @@ class Actor: def load_modules( self, - parent_main_data: dict[str, str]|None = None, ) -> None: ''' @@ -543,20 +551,13 @@ class Actor: ''' try: if self._spawn_method == 'trio': - if ( - parent_main_data is not None - and - 'init_main_from_name' in parent_main_data - ): + parent_data = self._parent_main_data + if 'init_main_from_name' in parent_data: _mp_fixup_main._fixup_main_from_name( - parent_main_data['init_main_from_name']) - elif ( - parent_main_data is not None - and - 'init_main_from_path' in parent_main_data - ): + parent_data['init_main_from_name']) + elif 'init_main_from_path' in parent_data: _mp_fixup_main._fixup_main_from_path( - parent_main_data['init_main_from_path']) + parent_data['init_main_from_path']) status: str = 'Attempting to import enabled modules:\n' @@ -842,7 +843,6 @@ class Actor: Channel, list[UnwrappedAddress]|None, list[str]|None, # preferred tpts - dict[str, str]|None, ]: ''' Bootstrap this local actor's runtime config from its parent by @@ -863,7 +863,6 @@ class Actor: await chan._do_handshake(aid=self.aid) accept_addrs: list[UnwrappedAddress]|None = None - parent_main_data: dict[str, str]|None = None if self._spawn_method == "trio": @@ -1024,13 +1023,12 @@ class Actor: spawnspec.enable_modules ) - parent_main_data = spawnspec._parent_main_data + self._parent_main_data = spawnspec._parent_main_data return ( chan, accept_addrs, - _state._runtime_vars['_enable_tpts'], - parent_main_data, + _state._runtime_vars['_enable_tpts'] ) # failed to connect back? @@ -1523,7 +1521,6 @@ async def async_main( # establish primary connection with immediate parent actor._parent_chan: Channel|None = None - parent_main_data: dict[str, str]|None = None # is this a sub-actor? # get runtime info from parent. @@ -1532,7 +1529,6 @@ async def async_main( actor._parent_chan, set_accept_addr_says_rent, maybe_preferred_transports_says_rent, - parent_main_data, ) = await actor._from_parent(parent_addr) accept_addrs: list[UnwrappedAddress] = [] @@ -1612,9 +1608,7 @@ async def async_main( # XXX: do this **after** establishing a channel to the parent # but **before** starting the message loop for that channel # such that import errors are properly propagated upwards - actor.load_modules( - parent_main_data=parent_main_data, - ) + actor.load_modules() # XXX TODO XXX: figuring out debugging of this # would somemwhat guarantee "self-hosted" runtime diff --git a/tractor/runtime/_supervise.py b/tractor/runtime/_supervise.py index a1ef950c..9645c982 100644 --- a/tractor/runtime/_supervise.py +++ b/tractor/runtime/_supervise.py @@ -247,6 +247,7 @@ class ActorNursery: # modules allowed to invoked funcs from enable_modules=enable_modules, loglevel=loglevel, + replay_parent_main=replay_parent_main, # verbatim relay this actor's registrar addresses registry_addrs=current_actor().registry_addrs, @@ -270,7 +271,6 @@ class ActorNursery: parent_addr, _rtv, # run time vars infect_asyncio=infect_asyncio, - replay_parent_main=replay_parent_main, proc_kwargs=proc_kwargs ) ) diff --git a/tractor/spawn/_mp_fixup_main.py b/tractor/spawn/_mp_fixup_main.py index 11d5f1c6..0189e6de 100644 --- a/tractor/spawn/_mp_fixup_main.py +++ b/tractor/spawn/_mp_fixup_main.py @@ -33,11 +33,16 @@ import runpy ORIGINAL_DIR = os.path.abspath(os.getcwd()) -def _mp_figure_out_main() -> dict[str, str]: +def _mp_figure_out_main( + replay_parent_main: bool = True, +) -> dict[str, str]: """Taken from ``multiprocessing.spawn.get_preparation_data()``. Retrieve parent actor `__main__` module data. """ + if not replay_parent_main: + return {} + d = {} # Figure out whether to initialise main in the subprocess as a module # or through direct execution (or to leave it alone entirely) diff --git a/tractor/spawn/_spawn.py b/tractor/spawn/_spawn.py index ad270036..9d89648c 100644 --- a/tractor/spawn/_spawn.py +++ b/tractor/spawn/_spawn.py @@ -50,7 +50,6 @@ from tractor.discovery._addr import UnwrappedAddress from tractor.runtime._portal import Portal from tractor.runtime._runtime import Actor from ._entry import _mp_main -from . import _mp_fixup_main from tractor._exceptions import ActorFailure from tractor.msg import ( types as msgtypes, @@ -421,7 +420,6 @@ async def new_proc( *, infect_asyncio: bool = False, - replay_parent_main: bool = True, task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED, proc_kwargs: dict[str, any] = {} @@ -442,7 +440,6 @@ async def new_proc( parent_addr, _runtime_vars, # run time vars infect_asyncio=infect_asyncio, - replay_parent_main=replay_parent_main, task_status=task_status, proc_kwargs=proc_kwargs ) @@ -460,7 +457,6 @@ async def trio_proc( _runtime_vars: dict[str, Any], # serialized and sent to _child *, infect_asyncio: bool = False, - replay_parent_main: bool = True, task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED, proc_kwargs: dict[str, any] = {} @@ -553,11 +549,7 @@ async def trio_proc( # send a "spawning specification" which configures the # initial runtime state of the child. sspec = msgtypes.SpawnSpec( - _parent_main_data=( - _mp_fixup_main._mp_figure_out_main() - if replay_parent_main - else {} - ), + _parent_main_data=subactor._parent_main_data, enable_modules=subactor.enable_modules, reg_addrs=subactor.reg_addrs, bind_addrs=bind_addrs, @@ -688,7 +680,6 @@ async def mp_proc( _runtime_vars: dict[str, Any], # serialized and sent to _child *, infect_asyncio: bool = False, - replay_parent_main: bool = True, task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED, proc_kwargs: dict[str, any] = {} From ea971d25aa67b7c1b04b353061c811792c4bd926 Mon Sep 17 00:00:00 2001 From: mahmoud Date: Mon, 6 Apr 2026 06:30:00 +0000 Subject: [PATCH 04/14] Rename parent-main inheritance flag. Use `inherit_parent_main` across the actor APIs and helper to better describe the behavior, and restore the reviewer note at child bootstrap where the inherited `__main__` data is copied from `SpawnSpec`. --- tests/test_spawning.py | 10 +++++----- tractor/runtime/_runtime.py | 9 +++++++-- tractor/runtime/_supervise.py | 10 +++++----- tractor/spawn/_mp_fixup_main.py | 4 ++-- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/tests/test_spawning.py b/tests/test_spawning.py index d61282f5..4bc0504e 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -204,23 +204,23 @@ def test_loglevel_propagated_to_subactor( assert 'yoyoyo' in captured.err -def test_start_actor_can_skip_parent_main_replay( +def test_start_actor_can_skip_parent_main_inheritance( start_method, reg_addr, monkeypatch, ): if start_method != 'trio': pytest.skip( - 'parent main replay opt-out only affects the trio spawn backend' + 'parent main inheritance opt-out only affects the trio spawn backend' ) from tractor.spawn import _mp_fixup_main monkeypatch.setattr( _mp_fixup_main, '_mp_figure_out_main', - lambda replay_parent_main=True: ( + lambda inherit_parent_main=True: ( {'init_main_from_name': __name__} - if replay_parent_main + if inherit_parent_main else {} ), ) @@ -238,7 +238,7 @@ def test_start_actor_can_skip_parent_main_replay( isolated = await an.run_in_actor( get_main_mod_name, name='isolated-parent-main', - replay_parent_main=False, + inherit_parent_main=False, ) assert await replaying.result() == '__mp_main__' diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index a9ae0f54..17119ffd 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -244,7 +244,7 @@ class Actor: loglevel: str|None = None, registry_addrs: list[Address]|None = None, spawn_method: str|None = None, - replay_parent_main: bool = True, + inherit_parent_main: bool = True, arbiter_addr: UnwrappedAddress|None = None, @@ -269,7 +269,7 @@ class Actor: # retrieve and store parent `__main__` data which # will be passed to children self._parent_main_data = _mp_fixup_main._mp_figure_out_main( - replay_parent_main, + inherit_parent_main, ) # TODO? only add this when `is_debug_mode() == True` no? @@ -1024,6 +1024,11 @@ class Actor: ) self._parent_main_data = spawnspec._parent_main_data + # XXX QUESTION(s)^^^ + # -[ ] already set in `.__init__()` right, but how is + # it diff from this blatant parent copy? + # -[ ] do we need/want the .__init__() value in + # just the root case orr? return ( chan, diff --git a/tractor/runtime/_supervise.py b/tractor/runtime/_supervise.py index 9645c982..3e7fa63c 100644 --- a/tractor/runtime/_supervise.py +++ b/tractor/runtime/_supervise.py @@ -194,7 +194,7 @@ class ActorNursery: loglevel: str|None = None, # set log level per subactor debug_mode: bool|None = None, infect_asyncio: bool = False, - replay_parent_main: bool = True, + inherit_parent_main: bool = True, # TODO: ideally we can rm this once we no longer have # a `._ria_nursery` since the dependent APIs have been @@ -207,7 +207,7 @@ class ActorNursery: Start a (daemon) actor: an process that has no designated "main task" besides the runtime. - Pass ``replay_parent_main=False`` to keep this child on its own + Pass ``inherit_parent_main=False`` to keep this child on its own bootstrap module instead of re-running the parent's ``__main__`` during startup. @@ -247,7 +247,7 @@ class ActorNursery: # modules allowed to invoked funcs from enable_modules=enable_modules, loglevel=loglevel, - replay_parent_main=replay_parent_main, + inherit_parent_main=inherit_parent_main, # verbatim relay this actor's registrar addresses registry_addrs=current_actor().registry_addrs, @@ -295,7 +295,7 @@ class ActorNursery: enable_modules: list[str] | None = None, loglevel: str | None = None, # set log level per subactor infect_asyncio: bool = False, - replay_parent_main: bool = True, + inherit_parent_main: bool = True, proc_kwargs: dict[str, any] = {}, **kwargs, # explicit args to ``fn`` @@ -327,7 +327,7 @@ class ActorNursery: # use the run_in_actor nursery nursery=self._ria_nursery, infect_asyncio=infect_asyncio, - replay_parent_main=replay_parent_main, + inherit_parent_main=inherit_parent_main, proc_kwargs=proc_kwargs ) diff --git a/tractor/spawn/_mp_fixup_main.py b/tractor/spawn/_mp_fixup_main.py index 0189e6de..7cf6bf15 100644 --- a/tractor/spawn/_mp_fixup_main.py +++ b/tractor/spawn/_mp_fixup_main.py @@ -34,13 +34,13 @@ ORIGINAL_DIR = os.path.abspath(os.getcwd()) def _mp_figure_out_main( - replay_parent_main: bool = True, + inherit_parent_main: bool = True, ) -> dict[str, str]: """Taken from ``multiprocessing.spawn.get_preparation_data()``. Retrieve parent actor `__main__` module data. """ - if not replay_parent_main: + if not inherit_parent_main: return {} d = {} From 00637764d90a91c3d3482682bb81e75816bbe8cf Mon Sep 17 00:00:00 2001 From: mahmoud Date: Mon, 6 Apr 2026 22:32:50 +0000 Subject: [PATCH 05/14] Address review follow-ups for parent-main inheritance opt-out Clean up mutable defaults, give parent-main bootstrap data a named type, and add direct start_actor coverage so the opt-out change is clearer to review. --- tests/test_spawning.py | 76 ++++++++++++++++++++++++++++----- tractor/runtime/_runtime.py | 10 +++-- tractor/runtime/_supervise.py | 8 ++-- tractor/spawn/_mp_fixup_main.py | 18 +++++++- 4 files changed, 93 insertions(+), 19 deletions(-) diff --git a/tests/test_spawning.py b/tests/test_spawning.py index 4bc0504e..ffa4b72c 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -204,6 +204,53 @@ def test_loglevel_propagated_to_subactor( assert 'yoyoyo' in captured.err +def test_run_in_actor_can_skip_parent_main_inheritance( + start_method, + reg_addr, + monkeypatch, +): + if start_method != 'trio': + pytest.skip( + 'parent main inheritance opt-out only affects the trio spawn backend' + ) + from tractor.spawn import _mp_fixup_main + + monkeypatch.setattr( + _mp_fixup_main, + '_mp_figure_out_main', + lambda inherit_parent_main=True: ( + {'init_main_from_name': __name__} + if inherit_parent_main + else {} + ), + ) + + async def main(): + async with tractor.open_nursery( + name='registrar', + start_method=start_method, + registry_addrs=[reg_addr], + ) as an: + replaying = await an.run_in_actor( + get_main_mod_name, + name='replaying-parent-main', + ) + isolated = await an.run_in_actor( + get_main_mod_name, + name='isolated-parent-main', + inherit_parent_main=False, + ) + + # Stdlib spawn re-runs an importable parent ``__main__`` as + # ``__mp_main__``; opting out should leave the child bootstrap + # module alone instead. + # https://docs.python.org/3/library/multiprocessing.html#the-spawn-and-forkserver-start-methods + assert await replaying.result() == '__mp_main__' + assert await isolated.result() == '__main__' + + trio.run(main) + + def test_start_actor_can_skip_parent_main_inheritance( start_method, reg_addr, @@ -225,23 +272,32 @@ def test_start_actor_can_skip_parent_main_inheritance( ), ) - async def main() -> None: + async def main(): async with tractor.open_nursery( name='registrar', start_method=start_method, registry_addrs=[reg_addr], ) as an: - replaying = await an.run_in_actor( - get_main_mod_name, - name='replaying-parent-main', + replaying = await an.start_actor( + 'replaying-parent-main', + enable_modules=[__name__], ) - isolated = await an.run_in_actor( - get_main_mod_name, - name='isolated-parent-main', + isolated = await an.start_actor( + 'isolated-parent-main', + enable_modules=[__name__], inherit_parent_main=False, ) - - assert await replaying.result() == '__mp_main__' - assert await isolated.result() == '__main__' + try: + assert await replaying.run_from_ns( + __name__, + 'get_main_mod_name', + ) == '__mp_main__' + assert await isolated.run_from_ns( + __name__, + 'get_main_mod_name', + ) == '__main__' + finally: + await replaying.cancel_actor() + await isolated.cancel_actor() trio.run(main) diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index 17119ffd..b5968640 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -119,6 +119,7 @@ from ..discovery._discovery import get_registry from ._portal import Portal from . import _state from ..spawn import _mp_fixup_main +from ..spawn._mp_fixup_main import ParentMainData from . import _rpc if TYPE_CHECKING: @@ -218,7 +219,7 @@ class Actor: return self._ipc_server # Information about `__main__` from parent - _parent_main_data: dict[str, str] + _parent_main_data: ParentMainData _parent_chan_cs: CancelScope|None = None _spawn_spec: msgtypes.SpawnSpec|None = None @@ -240,7 +241,7 @@ class Actor: name: str, uuid: str, *, - enable_modules: list[str] = [], + enable_modules: list[str] | None = None, loglevel: str|None = None, registry_addrs: list[Address]|None = None, spawn_method: str|None = None, @@ -268,12 +269,13 @@ class Actor: # retrieve and store parent `__main__` data which # will be passed to children - self._parent_main_data = _mp_fixup_main._mp_figure_out_main( - inherit_parent_main, + self._parent_main_data: ParentMainData = _mp_fixup_main._mp_figure_out_main( + inherit_parent_main=inherit_parent_main, ) # TODO? only add this when `is_debug_mode() == True` no? # always include debugging tools module + enable_modules = list(enable_modules or []) if _state.is_root_process(): enable_modules.append('tractor.devx.debug._tty_lock') diff --git a/tractor/runtime/_supervise.py b/tractor/runtime/_supervise.py index 3e7fa63c..a1675132 100644 --- a/tractor/runtime/_supervise.py +++ b/tractor/runtime/_supervise.py @@ -200,7 +200,7 @@ class ActorNursery: # a `._ria_nursery` since the dependent APIs have been # removed! nursery: trio.Nursery|None = None, - proc_kwargs: dict[str, any] = {} + proc_kwargs: dict[str, typing.Any] | None = None, ) -> Portal: ''' @@ -229,7 +229,8 @@ class ActorNursery: _rtv['_debug_mode'] = debug_mode self._at_least_one_child_in_debug = True - enable_modules = enable_modules or [] + enable_modules = list(enable_modules or []) + proc_kwargs = dict(proc_kwargs or {}) if rpc_module_paths: warnings.warn( @@ -296,7 +297,7 @@ class ActorNursery: loglevel: str | None = None, # set log level per subactor infect_asyncio: bool = False, inherit_parent_main: bool = True, - proc_kwargs: dict[str, any] = {}, + proc_kwargs: dict[str, typing.Any] | None = None, **kwargs, # explicit args to ``fn`` @@ -317,6 +318,7 @@ class ActorNursery: # use the explicit function name if not provided name = fn.__name__ + proc_kwargs = dict(proc_kwargs or {}) portal: Portal = await self.start_actor( name, enable_modules=[mod_path] + ( diff --git a/tractor/spawn/_mp_fixup_main.py b/tractor/spawn/_mp_fixup_main.py index 7cf6bf15..592a58f6 100644 --- a/tractor/spawn/_mp_fixup_main.py +++ b/tractor/spawn/_mp_fixup_main.py @@ -22,20 +22,34 @@ These helpers are needed for any spawing backend that doesn't already handle this. For example when using ``trio_run_in_process`` it is needed but obviously not when we're already using ``multiprocessing``. +These helpers mirror the stdlib spawn/forkserver bootstrap that rebuilds +the parent's `__main__` in a fresh child interpreter. In particular, we +capture enough info to later replay the parent's main module as +`__mp_main__` (or by path) in the child process. + +See: +https://docs.python.org/3/library/multiprocessing.html#the-spawn-and-forkserver-start-methods """ import os import sys import platform import types import runpy +from typing import NotRequired +from typing import TypedDict ORIGINAL_DIR = os.path.abspath(os.getcwd()) +class ParentMainData(TypedDict): + init_main_from_name: NotRequired[str] + init_main_from_path: NotRequired[str] + + def _mp_figure_out_main( inherit_parent_main: bool = True, -) -> dict[str, str]: +) -> ParentMainData: """Taken from ``multiprocessing.spawn.get_preparation_data()``. Retrieve parent actor `__main__` module data. @@ -43,7 +57,7 @@ def _mp_figure_out_main( if not inherit_parent_main: return {} - d = {} + d: ParentMainData = {} # Figure out whether to initialise main in the subprocess as a module # or through direct execution (or to leave it alone entirely) main_module = sys.modules['__main__'] From b883b27646aa7d5a9d040144b9136c439395d406 Mon Sep 17 00:00:00 2001 From: mahmoud Date: Mon, 6 Apr 2026 23:14:17 +0000 Subject: [PATCH 06/14] Exercise parent-main inheritance through spawn test support Move the subprocess probe into dedicated spawn test support files so the inheritance tests cover the real __main__ replay path without monkeypatching or inline script strings. --- tests/spawn_test_support/__init__.py | 1 + .../parent_main_inheritance_case.py | 63 ++++++++ .../parent_main_inheritance_support.py | 5 + tests/test_spawning.py | 139 ++++++++---------- 4 files changed, 127 insertions(+), 81 deletions(-) create mode 100644 tests/spawn_test_support/__init__.py create mode 100644 tests/spawn_test_support/parent_main_inheritance_case.py create mode 100644 tests/spawn_test_support/parent_main_inheritance_support.py diff --git a/tests/spawn_test_support/__init__.py b/tests/spawn_test_support/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/spawn_test_support/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/spawn_test_support/parent_main_inheritance_case.py b/tests/spawn_test_support/parent_main_inheritance_case.py new file mode 100644 index 00000000..fd9ccc1e --- /dev/null +++ b/tests/spawn_test_support/parent_main_inheritance_case.py @@ -0,0 +1,63 @@ +import json +from pathlib import Path +import sys + +import trio +import tractor + +from spawn_test_support.parent_main_inheritance_support import get_main_mod_name + + +async def main(api: str, output_path: str) -> None: + async with tractor.open_nursery(start_method='trio') as an: + if api == 'run_in_actor': + replaying = await an.run_in_actor( + get_main_mod_name, + name='replaying-parent-main', + ) + isolated = await an.run_in_actor( + get_main_mod_name, + name='isolated-parent-main', + inherit_parent_main=False, + ) + replaying_name = await replaying.result() + isolated_name = await isolated.result() + elif api == 'start_actor': + replaying = await an.start_actor( + 'replaying-parent-main', + enable_modules=[ + 'spawn_test_support.parent_main_inheritance_support', + ], + ) + isolated = await an.start_actor( + 'isolated-parent-main', + enable_modules=[ + 'spawn_test_support.parent_main_inheritance_support', + ], + inherit_parent_main=False, + ) + try: + replaying_name = await replaying.run_from_ns( + 'spawn_test_support.parent_main_inheritance_support', + 'get_main_mod_name', + ) + isolated_name = await isolated.run_from_ns( + 'spawn_test_support.parent_main_inheritance_support', + 'get_main_mod_name', + ) + finally: + await replaying.cancel_actor() + await isolated.cancel_actor() + else: + raise ValueError(f'Unknown api: {api}') + + Path(output_path).write_text( + json.dumps({ + 'replaying': replaying_name, + 'isolated': isolated_name, + }) + ) + + +if __name__ == '__main__': + trio.run(main, sys.argv[1], sys.argv[2]) diff --git a/tests/spawn_test_support/parent_main_inheritance_support.py b/tests/spawn_test_support/parent_main_inheritance_support.py new file mode 100644 index 00000000..bffe7766 --- /dev/null +++ b/tests/spawn_test_support/parent_main_inheritance_support.py @@ -0,0 +1,5 @@ +import sys + + +async def get_main_mod_name() -> str: + return sys.modules['__main__'].__name__ diff --git a/tests/test_spawning.py b/tests/test_spawning.py index ffa4b72c..77732050 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -3,6 +3,11 @@ Spawning basics """ from functools import partial +import json +import os +from pathlib import Path +import subprocess +import sys from typing import ( Any, ) @@ -17,6 +22,11 @@ data_to_pass_down = { 'doggy': 10, 'kitty': 4, } +_tests_dir = Path(__file__).resolve().parent +_repo_root = _tests_dir.parent +_parent_main_case_script = ( + _tests_dir / 'spawn_test_support' / 'parent_main_inheritance_case.py' +) async def spawn( @@ -173,6 +183,38 @@ async def get_main_mod_name() -> str: return sys.modules['__main__'].__name__ +def _run_parent_main_inheritance_script( + tmp_path: Path, + *, + api: str, +) -> dict[str, str]: + output_file = tmp_path / 'out.json' + + env = os.environ.copy() + old_pythonpath = env.get('PYTHONPATH') + env['PYTHONPATH'] = ( + f'{_tests_dir}{os.pathsep}{_repo_root}{os.pathsep}{old_pythonpath}' + if old_pythonpath + else f'{_tests_dir}{os.pathsep}{_repo_root}' + ) + proc = subprocess.run( + [ + sys.executable, + str(_parent_main_case_script), + api, + str(output_file), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + assert proc.returncode == 0, ( + f'stdout:\n{proc.stdout}\n\nstderr:\n{proc.stderr}' + ) + return json.loads(output_file.read_text()) + + def test_loglevel_propagated_to_subactor( start_method, capfd, @@ -206,98 +248,33 @@ def test_loglevel_propagated_to_subactor( def test_run_in_actor_can_skip_parent_main_inheritance( start_method, - reg_addr, - monkeypatch, + tmp_path, ): if start_method != 'trio': pytest.skip( 'parent main inheritance opt-out only affects the trio spawn backend' ) - from tractor.spawn import _mp_fixup_main - - monkeypatch.setattr( - _mp_fixup_main, - '_mp_figure_out_main', - lambda inherit_parent_main=True: ( - {'init_main_from_name': __name__} - if inherit_parent_main - else {} - ), - ) - - async def main(): - async with tractor.open_nursery( - name='registrar', - start_method=start_method, - registry_addrs=[reg_addr], - ) as an: - replaying = await an.run_in_actor( - get_main_mod_name, - name='replaying-parent-main', - ) - isolated = await an.run_in_actor( - get_main_mod_name, - name='isolated-parent-main', - inherit_parent_main=False, - ) - - # Stdlib spawn re-runs an importable parent ``__main__`` as - # ``__mp_main__``; opting out should leave the child bootstrap - # module alone instead. - # https://docs.python.org/3/library/multiprocessing.html#the-spawn-and-forkserver-start-methods - assert await replaying.result() == '__mp_main__' - assert await isolated.result() == '__main__' - - trio.run(main) + assert _run_parent_main_inheritance_script( + tmp_path, + api='run_in_actor', + ) == { + 'replaying': '__mp_main__', + 'isolated': '__main__', + } def test_start_actor_can_skip_parent_main_inheritance( start_method, - reg_addr, - monkeypatch, + tmp_path, ): if start_method != 'trio': pytest.skip( 'parent main inheritance opt-out only affects the trio spawn backend' ) - from tractor.spawn import _mp_fixup_main - - monkeypatch.setattr( - _mp_fixup_main, - '_mp_figure_out_main', - lambda inherit_parent_main=True: ( - {'init_main_from_name': __name__} - if inherit_parent_main - else {} - ), - ) - - async def main(): - async with tractor.open_nursery( - name='registrar', - start_method=start_method, - registry_addrs=[reg_addr], - ) as an: - replaying = await an.start_actor( - 'replaying-parent-main', - enable_modules=[__name__], - ) - isolated = await an.start_actor( - 'isolated-parent-main', - enable_modules=[__name__], - inherit_parent_main=False, - ) - try: - assert await replaying.run_from_ns( - __name__, - 'get_main_mod_name', - ) == '__mp_main__' - assert await isolated.run_from_ns( - __name__, - 'get_main_mod_name', - ) == '__main__' - finally: - await replaying.cancel_actor() - await isolated.cancel_actor() - - trio.run(main) + assert _run_parent_main_inheritance_script( + tmp_path, + api='start_actor', + ) == { + 'replaying': '__mp_main__', + 'isolated': '__main__', + } From c6c591e61a18e36556bc2c088ea9a785722484db Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 9 Apr 2026 19:14:09 -0400 Subject: [PATCH 07/14] Drop `spawn_test_support` pkg, inline parent-main tests Replace the subproc-based test harness with inline `tractor.open_nursery()` calls that directly check `actor._parent_main_data` instead of comparing `__main__.__name__` across a process boundary (which is a no-op under pytest bc the parent `__main__` is `pytest.__main__`). Deats, - delete `tests/spawn_test_support/` pkg (3 files) - add `check_parent_main_inheritance()` helper fn that asserts on `_parent_main_data` emptiness - rewrite both `run_in_actor` and `start_actor` parent-main tests as inline async fns - drop `tmp_path` fixture and unused imports Review: PR #434 (goodboy, Copilot) https://github.com/goodboy/tractor/pull/434 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/spawn_test_support/__init__.py | 1 - .../parent_main_inheritance_case.py | 63 -------- .../parent_main_inheritance_support.py | 5 - tests/test_spawning.py | 150 +++++++++++------- 4 files changed, 93 insertions(+), 126 deletions(-) delete mode 100644 tests/spawn_test_support/__init__.py delete mode 100644 tests/spawn_test_support/parent_main_inheritance_case.py delete mode 100644 tests/spawn_test_support/parent_main_inheritance_support.py diff --git a/tests/spawn_test_support/__init__.py b/tests/spawn_test_support/__init__.py deleted file mode 100644 index 8b137891..00000000 --- a/tests/spawn_test_support/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/spawn_test_support/parent_main_inheritance_case.py b/tests/spawn_test_support/parent_main_inheritance_case.py deleted file mode 100644 index fd9ccc1e..00000000 --- a/tests/spawn_test_support/parent_main_inheritance_case.py +++ /dev/null @@ -1,63 +0,0 @@ -import json -from pathlib import Path -import sys - -import trio -import tractor - -from spawn_test_support.parent_main_inheritance_support import get_main_mod_name - - -async def main(api: str, output_path: str) -> None: - async with tractor.open_nursery(start_method='trio') as an: - if api == 'run_in_actor': - replaying = await an.run_in_actor( - get_main_mod_name, - name='replaying-parent-main', - ) - isolated = await an.run_in_actor( - get_main_mod_name, - name='isolated-parent-main', - inherit_parent_main=False, - ) - replaying_name = await replaying.result() - isolated_name = await isolated.result() - elif api == 'start_actor': - replaying = await an.start_actor( - 'replaying-parent-main', - enable_modules=[ - 'spawn_test_support.parent_main_inheritance_support', - ], - ) - isolated = await an.start_actor( - 'isolated-parent-main', - enable_modules=[ - 'spawn_test_support.parent_main_inheritance_support', - ], - inherit_parent_main=False, - ) - try: - replaying_name = await replaying.run_from_ns( - 'spawn_test_support.parent_main_inheritance_support', - 'get_main_mod_name', - ) - isolated_name = await isolated.run_from_ns( - 'spawn_test_support.parent_main_inheritance_support', - 'get_main_mod_name', - ) - finally: - await replaying.cancel_actor() - await isolated.cancel_actor() - else: - raise ValueError(f'Unknown api: {api}') - - Path(output_path).write_text( - json.dumps({ - 'replaying': replaying_name, - 'isolated': isolated_name, - }) - ) - - -if __name__ == '__main__': - trio.run(main, sys.argv[1], sys.argv[2]) diff --git a/tests/spawn_test_support/parent_main_inheritance_support.py b/tests/spawn_test_support/parent_main_inheritance_support.py deleted file mode 100644 index bffe7766..00000000 --- a/tests/spawn_test_support/parent_main_inheritance_support.py +++ /dev/null @@ -1,5 +0,0 @@ -import sys - - -async def get_main_mod_name() -> str: - return sys.modules['__main__'].__name__ diff --git a/tests/test_spawning.py b/tests/test_spawning.py index 77732050..3f053e75 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -3,11 +3,6 @@ Spawning basics """ from functools import partial -import json -import os -from pathlib import Path -import subprocess -import sys from typing import ( Any, ) @@ -22,11 +17,6 @@ data_to_pass_down = { 'doggy': 10, 'kitty': 4, } -_tests_dir = Path(__file__).resolve().parent -_repo_root = _tests_dir.parent -_parent_main_case_script = ( - _tests_dir / 'spawn_test_support' / 'parent_main_inheritance_case.py' -) async def spawn( @@ -178,41 +168,41 @@ async def check_loglevel(level): log.critical('yoyoyo') -async def get_main_mod_name() -> str: - import sys - return sys.modules['__main__'].__name__ +async def check_parent_main_inheritance( + expect_inherited: bool, +) -> bool: + ''' + Assert that the child actor's ``_parent_main_data`` matches the + ``inherit_parent_main`` flag it was spawned with. + With the trio spawn backend the parent's ``__main__`` bootstrap + data is captured and forwarded to each child so it can replay + the parent's ``__main__`` as ``__mp_main__``, mirroring the + stdlib ``multiprocessing`` bootstrap: + https://docs.python.org/3/library/multiprocessing.html#the-spawn-and-forkserver-start-methods -def _run_parent_main_inheritance_script( - tmp_path: Path, - *, - api: str, -) -> dict[str, str]: - output_file = tmp_path / 'out.json' + When ``inherit_parent_main=False`` the data dict is empty + (``{}``) so no fixup ever runs and the child keeps its own + ``__main__`` untouched. - env = os.environ.copy() - old_pythonpath = env.get('PYTHONPATH') - env['PYTHONPATH'] = ( - f'{_tests_dir}{os.pathsep}{_repo_root}{os.pathsep}{old_pythonpath}' - if old_pythonpath - else f'{_tests_dir}{os.pathsep}{_repo_root}' + NOTE: under pytest the parent ``__main__`` is + ``pytest.__main__`` whose ``_fixup_main_from_name()`` is a + no-op (the name ends with ``.__main__``), so we cannot observe + a difference in ``sys.modules['__main__'].__name__`` between + the two modes. Checking ``_parent_main_data`` directly is the + most reliable verification that the flag is threaded through + correctly; a ``RemoteActorError[AssertionError]`` propagates + on mismatch. + ''' + import tractor + actor = tractor.current_actor() + has_data: bool = bool(actor._parent_main_data) + assert has_data == expect_inherited, ( + f'Expected _parent_main_data to be ' + f'{"non-empty" if expect_inherited else "empty"}, ' + f'got: {actor._parent_main_data!r}' ) - proc = subprocess.run( - [ - sys.executable, - str(_parent_main_case_script), - api, - str(output_file), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - assert proc.returncode == 0, ( - f'stdout:\n{proc.stdout}\n\nstderr:\n{proc.stderr}' - ) - return json.loads(output_file.read_text()) + return has_data def test_loglevel_propagated_to_subactor( @@ -248,33 +238,79 @@ def test_loglevel_propagated_to_subactor( def test_run_in_actor_can_skip_parent_main_inheritance( start_method, - tmp_path, ): + ''' + Verify ``inherit_parent_main=False`` on ``run_in_actor()`` + prevents parent ``__main__`` data from reaching the child. + + ''' if start_method != 'trio': pytest.skip( 'parent main inheritance opt-out only affects the trio spawn backend' ) - assert _run_parent_main_inheritance_script( - tmp_path, - api='run_in_actor', - ) == { - 'replaying': '__mp_main__', - 'isolated': '__main__', - } + + async def main(): + async with tractor.open_nursery(start_method='trio') as an: + + # Default: child receives parent __main__ bootstrap data + replaying = await an.run_in_actor( + check_parent_main_inheritance, + name='replaying-parent-main', + expect_inherited=True, + ) + await replaying.result() + + # Opt-out: child gets no parent __main__ data + isolated = await an.run_in_actor( + check_parent_main_inheritance, + name='isolated-parent-main', + inherit_parent_main=False, + expect_inherited=False, + ) + await isolated.result() + + trio.run(main) def test_start_actor_can_skip_parent_main_inheritance( start_method, - tmp_path, ): + ''' + Verify ``inherit_parent_main=False`` on ``start_actor()`` + prevents parent ``__main__`` data from reaching the child. + + ''' if start_method != 'trio': pytest.skip( 'parent main inheritance opt-out only affects the trio spawn backend' ) - assert _run_parent_main_inheritance_script( - tmp_path, - api='start_actor', - ) == { - 'replaying': '__mp_main__', - 'isolated': '__main__', - } + + async def main(): + async with tractor.open_nursery(start_method='trio') as an: + + # Default: child receives parent __main__ bootstrap data + replaying = await an.start_actor( + 'replaying-parent-main', + enable_modules=[__name__], + ) + result = await replaying.run( + check_parent_main_inheritance, + expect_inherited=True, + ) + assert result is True + await replaying.cancel_actor() + + # Opt-out: child gets no parent __main__ data + isolated = await an.start_actor( + 'isolated-parent-main', + enable_modules=[__name__], + inherit_parent_main=False, + ) + result = await isolated.run( + check_parent_main_inheritance, + expect_inherited=False, + ) + assert result is False + await isolated.cancel_actor() + + trio.run(main) From acf65682756d2815ff7bb5de0c69e719b7dbb56e Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 10 Apr 2026 11:29:34 -0400 Subject: [PATCH 08/14] Clarify `inherit_parent_main` docstring scope Note the opt-out only applies to the trio spawn backend; `multiprocessing` `spawn`/`forkserver` reconstruct `__main__` via stdlib bootstrap. Review: PR #438 (Copilot) https://github.com/goodboy/tractor/pull/438 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/runtime/_supervise.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tractor/runtime/_supervise.py b/tractor/runtime/_supervise.py index a1675132..6d2d573f 100644 --- a/tractor/runtime/_supervise.py +++ b/tractor/runtime/_supervise.py @@ -207,9 +207,12 @@ class ActorNursery: Start a (daemon) actor: an process that has no designated "main task" besides the runtime. - Pass ``inherit_parent_main=False`` to keep this child on its own - bootstrap module instead of re-running the parent's ``__main__`` - during startup. + 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. + This does not affect ``multiprocessing`` ``spawn`` or + ``forkserver`` which reconstruct the parent's ``__main__`` as + part of their normal stdlib bootstrap. ''' __runtimeframe__: int = 1 # noqa From 656c6c30d198786f71dcfc6968d2157fa63bb075 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 10 Apr 2026 11:30:50 -0400 Subject: [PATCH 09/14] Delegate `_mp_fixup_main` to stdlib `mp.spawn` Drop hand-copied `_fixup_main_from_name()` and `_fixup_main_from_path()` in favor of direct re-exports from `multiprocessing.spawn`. Simplify `_mp_figure_out_main()` to call stdlib's `get_preparation_data()` instead of reimplementing `__main__` module inspection inline. Also, - drop `ORIGINAL_DIR` global and `os`, `sys`, `platform`, `types`, `runpy` imports. - pop `authkey` from prep data (unserializable and unneeded by our spawn path). - update mod docstring to reflect delegation. Review: PR #438 (Copilot) https://github.com/goodboy/tractor/pull/438 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/spawn/_mp_fixup_main.py | 136 ++++++++++---------------------- 1 file changed, 40 insertions(+), 96 deletions(-) diff --git a/tractor/spawn/_mp_fixup_main.py b/tractor/spawn/_mp_fixup_main.py index 592a58f6..d1e2c79e 100644 --- a/tractor/spawn/_mp_fixup_main.py +++ b/tractor/spawn/_mp_fixup_main.py @@ -14,34 +14,42 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -""" -Helpers pulled mostly verbatim from ``multiprocessing.spawn`` +''' +(Originally) Helpers pulled verbatim from ``multiprocessing.spawn`` to aid with "fixing up" the ``__main__`` module in subprocesses. -These helpers are needed for any spawing backend that doesn't already -handle this. For example when using ``trio_run_in_process`` it is needed -but obviously not when we're already using ``multiprocessing``. +Now just delegates directly to appropriate `mp.spawn` fns. -These helpers mirror the stdlib spawn/forkserver bootstrap that rebuilds -the parent's `__main__` in a fresh child interpreter. In particular, we -capture enough info to later replay the parent's main module as -`__mp_main__` (or by path) in the child process. +Note +---- +These helpers are needed for any spawing backend that doesn't already +handle this. For example it's needed when using our +`start_method='trio' backend but not when we're already using +a ``multiprocessing`` backend such as 'mp_spawn', 'mp_forkserver'. + +?TODO? +- what will be required for an eventual subint backend? + +The helpers imported from `mp.spawn` provide the stdlib's +spawn/forkserver bootstrap that rebuilds the parent's `__main__` in +a fresh child interpreter. In particular, we capture enough info to +later replay the parent's main module as `__mp_main__` (or by path) +in the child process. See: https://docs.python.org/3/library/multiprocessing.html#the-spawn-and-forkserver-start-methods -""" -import os -import sys -import platform -import types -import runpy + +''' +import multiprocessing as mp +from multiprocessing.spawn import ( + _fixup_main_from_name as _fixup_main_from_name, + _fixup_main_from_path as _fixup_main_from_path, + get_preparation_data, +) from typing import NotRequired from typing import TypedDict -ORIGINAL_DIR = os.path.abspath(os.getcwd()) - - class ParentMainData(TypedDict): init_main_from_name: NotRequired[str] init_main_from_path: NotRequired[str] @@ -50,86 +58,22 @@ class ParentMainData(TypedDict): def _mp_figure_out_main( inherit_parent_main: bool = True, ) -> ParentMainData: - """Taken from ``multiprocessing.spawn.get_preparation_data()``. + ''' + Delegate to `multiprocessing.spawn.get_preparation_data()` + when `inherit_parent_main=True`. - Retrieve parent actor `__main__` module data. - """ + Retrieve parent (actor) proc's `__main__` module data. + + ''' if not inherit_parent_main: return {} - d: ParentMainData = {} - # Figure out whether to initialise main in the subprocess as a module - # or through direct execution (or to leave it alone entirely) - main_module = sys.modules['__main__'] - main_mod_name = getattr(main_module.__spec__, "name", None) - if main_mod_name is not None: - d['init_main_from_name'] = main_mod_name - # elif sys.platform != 'win32' or (not WINEXE and not WINSERVICE): - elif platform.system() != 'Windows': - main_path = getattr(main_module, '__file__', None) - if main_path is not None: - if ( - not os.path.isabs(main_path) and ( - ORIGINAL_DIR is not None) - ): - # process.ORIGINAL_DIR is not None): - # main_path = os.path.join(process.ORIGINAL_DIR, main_path) - main_path = os.path.join(ORIGINAL_DIR, main_path) - d['init_main_from_path'] = os.path.normpath(main_path) - + d: ParentMainData + proc: mp.Process = mp.current_process() + d: dict = get_preparation_data( + name=proc.name, + ) + # XXX, unserializable (and uneeded by us) by default + # see `mp.spawn.get_preparation_data()` impl deats. + d.pop('authkey') return d - - -# Multiprocessing module helpers to fix up the main module in -# spawned subprocesses -def _fixup_main_from_name(mod_name: str) -> None: - # __main__.py files for packages, directories, zip archives, etc, run - # their "main only" code unconditionally, so we don't even try to - # populate anything in __main__, nor do we make any changes to - # __main__ attributes - current_main = sys.modules['__main__'] - if mod_name == "__main__" or mod_name.endswith(".__main__"): - return - - # If this process was forked, __main__ may already be populated - if getattr(current_main.__spec__, "name", None) == mod_name: - return - - # Otherwise, __main__ may contain some non-main code where we need to - # support unpickling it properly. We rerun it as __mp_main__ and make - # the normal __main__ an alias to that - # old_main_modules.append(current_main) - main_module = types.ModuleType("__mp_main__") - main_content = runpy.run_module(mod_name, - run_name="__mp_main__", - alter_sys=True) # type: ignore - main_module.__dict__.update(main_content) - sys.modules['__main__'] = sys.modules['__mp_main__'] = main_module - - -def _fixup_main_from_path(main_path: str) -> None: - # If this process was forked, __main__ may already be populated - current_main = sys.modules['__main__'] - - # Unfortunately, the main ipython launch script historically had no - # "if __name__ == '__main__'" guard, so we work around that - # by treating it like a __main__.py file - # See https://github.com/ipython/ipython/issues/4698 - main_name = os.path.splitext(os.path.basename(main_path))[0] - if main_name == 'ipython': - return - - # Otherwise, if __file__ already has the setting we expect, - # there's nothing more to do - if getattr(current_main, '__file__', None) == main_path: - return - - # If the parent process has sent a path through rather than a module - # name we assume it is an executable script that may contain - # non-main code that needs to be executed - # old_main_modules.append(current_main) - main_module = types.ModuleType("__mp_main__") - main_content = runpy.run_path(main_path, - run_name="__mp_main__") # type: ignore - main_module.__dict__.update(main_content) - sys.modules['__main__'] = sys.modules['__mp_main__'] = main_module From e8f1eca8d24fb708665c00ea64012b6ceed1a669 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 10 Apr 2026 12:03:59 -0400 Subject: [PATCH 10/14] Tighten `test_spawning` types, parametrize loglevel Parametrize `test_loglevel_propagated_to_subactor` across `'debug'`, `'cancel'`, `'critical'` levels (was hardcoded to just `'critical'`) and move it above the parent-main tests for logical grouping. Also, - add `start_method: str` annotations throughout - use `portal.wait_for_result()` in `test_most_beautiful_word` (replaces `.result()`) - expand mod docstring to describe test coverage - reformat `check_parent_main_inheritance` docstr Review: PR #438 (Copilot) https://github.com/goodboy/tractor/pull/438 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_spawning.py | 120 ++++++++++++++++++++++++----------------- 1 file changed, 70 insertions(+), 50 deletions(-) diff --git a/tests/test_spawning.py b/tests/test_spawning.py index 3f053e75..52296f34 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -1,5 +1,12 @@ """ -Spawning basics +Spawning basics including audit of, + +- subproc boostrap, such as subactor runtime-data/config inheritance, +- basic (and mostly legacy) `ActorNursery` subactor starting and + cancel APIs. + +Simple (and generally legacy) examples from the original +API design. """ from functools import partial @@ -98,7 +105,9 @@ async def movie_theatre_question(): @tractor_test -async def test_movie_theatre_convo(start_method): +async def test_movie_theatre_convo( + start_method: str, +): ''' The main ``tractor`` routine. @@ -157,7 +166,9 @@ async def test_most_beautiful_word( # this should pull the cached final result already captured during # the nursery block exit. - print(await portal.result()) + res: Any = await portal.wait_for_result() + assert res == return_value + print(res) async def check_loglevel(level): @@ -168,53 +179,24 @@ async def check_loglevel(level): log.critical('yoyoyo') -async def check_parent_main_inheritance( - expect_inherited: bool, -) -> bool: - ''' - Assert that the child actor's ``_parent_main_data`` matches the - ``inherit_parent_main`` flag it was spawned with. - - With the trio spawn backend the parent's ``__main__`` bootstrap - data is captured and forwarded to each child so it can replay - the parent's ``__main__`` as ``__mp_main__``, mirroring the - stdlib ``multiprocessing`` bootstrap: - https://docs.python.org/3/library/multiprocessing.html#the-spawn-and-forkserver-start-methods - - When ``inherit_parent_main=False`` the data dict is empty - (``{}``) so no fixup ever runs and the child keeps its own - ``__main__`` untouched. - - NOTE: under pytest the parent ``__main__`` is - ``pytest.__main__`` whose ``_fixup_main_from_name()`` is a - no-op (the name ends with ``.__main__``), so we cannot observe - a difference in ``sys.modules['__main__'].__name__`` between - the two modes. Checking ``_parent_main_data`` directly is the - most reliable verification that the flag is threaded through - correctly; a ``RemoteActorError[AssertionError]`` propagates - on mismatch. - ''' - import tractor - actor = tractor.current_actor() - has_data: bool = bool(actor._parent_main_data) - assert has_data == expect_inherited, ( - f'Expected _parent_main_data to be ' - f'{"non-empty" if expect_inherited else "empty"}, ' - f'got: {actor._parent_main_data!r}' - ) - return has_data - - +@pytest.mark.parametrize( + 'level', [ + 'debug', + 'cancel', + 'critical' + ], + ids='loglevel={}'.format, +) def test_loglevel_propagated_to_subactor( - start_method, - capfd, - reg_addr, + capfd: pytest.CaptureFixture, + start_method: str, + reg_addr: tuple, + level: str, ): if start_method == 'mp_forkserver': pytest.skip( - "a bug with `capfd` seems to make forkserver capture not work?") - - level = 'critical' + "a bug with `capfd` seems to make forkserver capture not work?" + ) async def main(): async with tractor.open_nursery( @@ -236,8 +218,46 @@ def test_loglevel_propagated_to_subactor( assert 'yoyoyo' in captured.err +async def check_parent_main_inheritance( + expect_inherited: bool, +) -> bool: + ''' + Assert that the child actor's ``_parent_main_data`` matches the + ``inherit_parent_main`` flag it was spawned with. + + With the trio spawn backend the parent's ``__main__`` bootstrap + data is captured and forwarded to each child so it can replay + the parent's ``__main__`` as ``__mp_main__``, mirroring the + stdlib ``multiprocessing`` bootstrap: + https://docs.python.org/3/library/multiprocessing.html#the-spawn-and-forkserver-start-methods + + When ``inherit_parent_main=False`` the data dict is empty + (``{}``) so no fixup ever runs and the child keeps its own + ``__main__`` untouched. + + NOTE: under `pytest` the parent ``__main__`` is + ``pytest.__main__`` whose ``_fixup_main_from_name()`` is a no-op + (the name ends with ``.__main__``), so we cannot observe + a difference in ``sys.modules['__main__'].__name__`` between the + two modes. Checking ``_parent_main_data`` directly is the most + reliable verification that the flag is threaded through + correctly; a ``RemoteActorError[AssertionError]`` propagates on + mismatch. + + ''' + import tractor + actor: tractor.Actor = tractor.current_actor() + has_data: bool = bool(actor._parent_main_data) + assert has_data == expect_inherited, ( + f'Expected _parent_main_data to be ' + f'{"non-empty" if expect_inherited else "empty"}, ' + f'got: {actor._parent_main_data!r}' + ) + return has_data + + def test_run_in_actor_can_skip_parent_main_inheritance( - start_method, + start_method: str, # <- only support on `trio` backend rn. ): ''' Verify ``inherit_parent_main=False`` on ``run_in_actor()`` @@ -246,7 +266,7 @@ def test_run_in_actor_can_skip_parent_main_inheritance( ''' if start_method != 'trio': pytest.skip( - 'parent main inheritance opt-out only affects the trio spawn backend' + 'parent main-inheritance opt-out only affects the trio backend' ) async def main(): @@ -273,7 +293,7 @@ def test_run_in_actor_can_skip_parent_main_inheritance( def test_start_actor_can_skip_parent_main_inheritance( - start_method, + start_method: str, # <- only support on `trio` backend rn. ): ''' Verify ``inherit_parent_main=False`` on ``start_actor()`` @@ -282,7 +302,7 @@ def test_start_actor_can_skip_parent_main_inheritance( ''' if start_method != 'trio': pytest.skip( - 'parent main inheritance opt-out only affects the trio spawn backend' + 'parent main-inheritance opt-out only affects the trio backend' ) async def main(): From 27bf566d75dee66562711df5d1ec87c38c1acde6 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 10 Apr 2026 12:05:56 -0400 Subject: [PATCH 11/14] Guard `_mp_fixup_main` on non-empty parent data Use walrus `:=` to combine the assignment and truthiness check for `_parent_main_data` into the `if` condition, cleanly skipping the fixup block when `inherit_parent_main=False` yields `{}`. Review: PR #438 (Copilot) https://github.com/goodboy/tractor/pull/438 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/runtime/_runtime.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index b5968640..477d8f9b 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -552,11 +552,15 @@ class Actor: ''' try: - if self._spawn_method == 'trio': - parent_data = self._parent_main_data + if ( + self._spawn_method == 'trio' + and + (parent_data := self._parent_main_data) + ): if 'init_main_from_name' in parent_data: _mp_fixup_main._fixup_main_from_name( parent_data['init_main_from_name']) + elif 'init_main_from_path' in parent_data: _mp_fixup_main._fixup_main_from_path( parent_data['init_main_from_path']) From a0a766867011edea5b86077c292d97d26d3b5edb Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 10 Apr 2026 14:53:24 -0400 Subject: [PATCH 12/14] Fix typos + typing in `_mp_fixup_main` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "spawing" → "spawning", close unbalanced backtick on `` `start_method='trio'` `` - "uneeded" → "unneeded", "deats" → "details" - Remove double `d` annotation; filter `get_preparation_data()` result into only `ParentMainData` keys before returning - Use `pop('authkey', None)` for safety Review: PR #1 (Copilot) https://github.com/mahmoudhas/tractor/pull/1#pullrequestreview-4091096072 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/spawn/_mp_fixup_main.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tractor/spawn/_mp_fixup_main.py b/tractor/spawn/_mp_fixup_main.py index d1e2c79e..2bd19d1b 100644 --- a/tractor/spawn/_mp_fixup_main.py +++ b/tractor/spawn/_mp_fixup_main.py @@ -22,9 +22,9 @@ Now just delegates directly to appropriate `mp.spawn` fns. Note ---- -These helpers are needed for any spawing backend that doesn't already +These helpers are needed for any spawning backend that doesn't already handle this. For example it's needed when using our -`start_method='trio' backend but not when we're already using +`start_method='trio'` backend but not when we're already using a ``multiprocessing`` backend such as 'mp_spawn', 'mp_forkserver'. ?TODO? @@ -68,12 +68,18 @@ def _mp_figure_out_main( if not inherit_parent_main: return {} - d: ParentMainData proc: mp.Process = mp.current_process() - d: dict = get_preparation_data( + prep_data: dict = get_preparation_data( name=proc.name, ) - # XXX, unserializable (and uneeded by us) by default - # see `mp.spawn.get_preparation_data()` impl deats. - d.pop('authkey') + # XXX, unserializable (and unneeded by us) by default + # see `mp.spawn.get_preparation_data()` impl details. + prep_data.pop('authkey', None) + + d: ParentMainData = {} + if 'init_main_from_name' in prep_data: + d['init_main_from_name'] = prep_data['init_main_from_name'] + if 'init_main_from_path' in prep_data: + d['init_main_from_path'] = prep_data['init_main_from_path'] + return d From 570c975f1467749425ca353946c761ffb0b18b50 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 10 Apr 2026 14:55:09 -0400 Subject: [PATCH 13/14] Fix test typo + use `wait_for_result()` API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "boostrap" → "bootstrap" in mod docstring - replace deprecated `portal.result()` with `portal.wait_for_result()` + value assertion inside the nursery block Review: PR #1 (Copilot) https://github.com/mahmoudhas/tractor/pull/1#pullrequestreview-4091096072 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_spawning.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_spawning.py b/tests/test_spawning.py index 52296f34..7e230085 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -1,7 +1,7 @@ """ Spawning basics including audit of, -- subproc boostrap, such as subactor runtime-data/config inheritance, +- subproc bootstrap, such as subactor runtime-data/config inheritance, - basic (and mostly legacy) `ActorNursery` subactor starting and cancel APIs. @@ -160,7 +160,8 @@ async def test_most_beautiful_word( name='some_linguist', ) - print(await portal.result()) + res: Any = await portal.wait_for_result() + assert res == return_value # The ``async with`` will unblock here since the 'some_linguist' # actor has completed its main task ``cellar_door``. From ca1b01f926ec87f0907ad79a9ab715c0adfb6038 Mon Sep 17 00:00:00 2001 From: mahmoud Date: Fri, 10 Apr 2026 20:49:16 +0000 Subject: [PATCH 14/14] mpi integration test --- examples/integration/mpi4py/__init__.py | 0 examples/integration/mpi4py/_child.py | 5 ++ .../integration/mpi4py/inherit_parent_main.py | 50 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 examples/integration/mpi4py/__init__.py create mode 100644 examples/integration/mpi4py/_child.py create mode 100644 examples/integration/mpi4py/inherit_parent_main.py diff --git a/examples/integration/mpi4py/__init__.py b/examples/integration/mpi4py/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/integration/mpi4py/_child.py b/examples/integration/mpi4py/_child.py new file mode 100644 index 00000000..e9d7186a --- /dev/null +++ b/examples/integration/mpi4py/_child.py @@ -0,0 +1,5 @@ +import os + + +async def child_fn() -> str: + return f"child OK pid={os.getpid()}" diff --git a/examples/integration/mpi4py/inherit_parent_main.py b/examples/integration/mpi4py/inherit_parent_main.py new file mode 100644 index 00000000..60e30a95 --- /dev/null +++ b/examples/integration/mpi4py/inherit_parent_main.py @@ -0,0 +1,50 @@ +""" +Integration test: spawning tractor actors from an MPI process. + +When a parent is launched via ``mpirun``, Open MPI sets ``OMPI_*`` env +vars that bind ``MPI_Init`` to the ``orted`` daemon. Tractor children +inherit those env vars, so if ``inherit_parent_main=True`` (the default) +the child re-executes ``__main__``, re-imports ``mpi4py``, and +``MPI_Init_thread`` fails because the child was never spawned by +``orted``:: + + getting local rank failed + --> Returned value No permission (-17) instead of ORTE_SUCCESS + +Passing ``inherit_parent_main=False`` and placing RPC functions in a +separate importable module (``_child``) avoids the re-import entirely. + +Usage:: + + mpirun --allow-run-as-root -np 1 python -m \ + examples.integration.mpi4py.inherit_parent_main +""" + +from mpi4py import MPI + +import os +import trio +import tractor + +from ._child import child_fn + + +async def main() -> None: + rank = MPI.COMM_WORLD.Get_rank() + print(f"[parent] rank={rank} pid={os.getpid()}", flush=True) + + async with tractor.open_nursery(start_method='trio') as an: + portal = await an.start_actor( + 'mpi-child', + enable_modules=[child_fn.__module__], + # Without this the child replays __main__, which + # re-imports mpi4py and crashes on MPI_Init. + inherit_parent_main=False, + ) + result = await portal.run(child_fn) + print(f"[parent] got: {result}", flush=True) + await portal.cancel_actor() + + +if __name__ == "__main__": + trio.run(main)