Detect child death before parent handshake
Race the initial peer handshake against process exit during Trio
child bootstrap so a dead child cannot park its spawning task.
Restore and harden the design originally implemented in `3b0724eb`.
Deats,
- register peer events before provisional nursery publication
- raise `ActorFailure` with child status when process death wins
- prioritize observed death over a simultaneous handshake
- preserve waiter exceptions without `ExceptionGroup` wrapping
- log expected losing-waiter cancellation at debug level
- remove exact failed-startup peer events during backend cleanup
- cover controlled schedules and full Trio backend cleanup
Based-on: 3b0724eba8
Prompt-IO: ai/prompt-io/opencode/20260828T045119Z_fb6d81d3_prompt_io.md
(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
parent
7dfefddae3
commit
dc72ecdfcd
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: trio-pre-handshake-child-death
|
||||||
|
timestamp: 2026-08-28T04:51:19Z
|
||||||
|
git_ref: fb6d81d3
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260828T045119Z_fb6d81d3_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human approved the next WireGuard Layer C implementation step after
|
||||||
|
committing child-side network namespace bootstrap.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Restore and harden the boot-race design originally implemented by
|
||||||
|
`3b0724eba85b4014170ed95773e1e41a60d5c513` before transferring
|
||||||
|
namespace FDs. Race the initial peer handshake against child-process
|
||||||
|
death so a pre-connection bootstrap failure surfaces instead of
|
||||||
|
parking `ActorNursery.start_actor()` forever.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/spawn/_spawn.py` - provide the generic handshake/process
|
||||||
|
death race.
|
||||||
|
- `tractor/spawn/_trio.py` - use the race during Trio child startup.
|
||||||
|
- `tests/test_spawning.py` - cover handshake-first and death-first
|
||||||
|
schedules, simultaneous outcomes, waiter errors, and backend cleanup.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human approved continued Layer C implementation. The agent narrowed
|
||||||
|
the next slice to failure detection after analysis showed FD transfer
|
||||||
|
would otherwise introduce a parent hang. The human then identified the
|
||||||
|
earlier `wait_for_peer_or_proc_death()` implementation and required
|
||||||
|
explicit credit; the source docstring and commit message now cite its
|
||||||
|
original commit rather than a later cherry-pick. The human directly
|
||||||
|
reflowed `_trio.py` to the project line limit and normalized nearby
|
||||||
|
quotes. The human also required naming the race helper in its lifecycle
|
||||||
|
comment, explicit `trio.Cancelled` handling in waiter tests, expected
|
||||||
|
cancellation diagnostics, and a `trio.Process`-constrained test double.
|
||||||
|
After mainline portability verification, the human chose to commit the
|
||||||
|
fix on the current WireGuard branch and require its cherry-pick onto
|
||||||
|
`main` as a blocker in the eventual WireGuard pull request.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-28T04:51:19Z
|
||||||
|
git_ref: fb6d81d3
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - detect child death before parent handshake
|
||||||
|
|
||||||
|
The human asked to continue WireGuard Layer C after child entrypoints
|
||||||
|
learned to consume inherited network namespace capabilities.
|
||||||
|
|
||||||
|
## Generated code
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_spawn.py tractor/spawn/_trio.py tests/test_spawning.py`
|
||||||
|
|
||||||
|
Race a spawned child's initial parent handshake against its process
|
||||||
|
wait operation. Return the established channel when the handshake wins;
|
||||||
|
raise `ActorFailure` with the exit status when the process dies first.
|
||||||
|
Use the race in the Trio exec backend instead of waiting indefinitely
|
||||||
|
on `IPCServer.wait_for_peer()`.
|
||||||
|
|
||||||
|
Add deterministic Trio tests for handshake-first, death-first,
|
||||||
|
simultaneous, and waiter-error schedules, controlling readiness with
|
||||||
|
events rather than sleeps. Exercise the full Trio backend with a fake
|
||||||
|
dead process to prove pre-publication event registration and exact
|
||||||
|
failed-startup cleanup.
|
||||||
|
|
||||||
|
## Scope boundary
|
||||||
|
|
||||||
|
This prerequisite prevents pre-connection namespace-entry failures
|
||||||
|
from hanging their parent. Namespace FD transfer itself remains the
|
||||||
|
next commit.
|
||||||
|
|
@ -10,15 +10,25 @@ API design.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
)
|
)
|
||||||
|
from unittest.mock import (
|
||||||
|
AsyncMock,
|
||||||
|
MagicMock,
|
||||||
|
)
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
|
from tractor._exceptions import ActorFailure
|
||||||
from tractor._testing import tractor_test
|
from tractor._testing import tractor_test
|
||||||
|
from tractor.spawn import (
|
||||||
|
_spawn,
|
||||||
|
_trio,
|
||||||
|
)
|
||||||
|
|
||||||
data_to_pass_down = {
|
data_to_pass_down = {
|
||||||
'doggy': 10,
|
'doggy': 10,
|
||||||
|
|
@ -26,6 +36,396 @@ data_to_pass_down = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_peer_handshake_wins_child_boot_race() -> None:
|
||||||
|
'''
|
||||||
|
A connected child must cancel process-death monitoring cleanly.
|
||||||
|
|
||||||
|
Start the death waiter first and hold it at a checkpoint. Let the
|
||||||
|
fake server then return one peer event and channel. The helper must
|
||||||
|
preserve the normal handshake result and cancel the losing process
|
||||||
|
waiter before its nursery exits. The fake explicitly catches and
|
||||||
|
re-raises `trio.Cancelled` to prove cancellation caused its exit.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Control the handshake-first schedule with Trio events.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('handshake-child', 'test')
|
||||||
|
death_started = trio.Event()
|
||||||
|
death_cancelled = trio.Event()
|
||||||
|
peer_event = trio.Event()
|
||||||
|
channel = object()
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Return the peer only after death monitoring is active.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
await death_started.wait()
|
||||||
|
return (peer_event, channel)
|
||||||
|
|
||||||
|
async def wait_for_death() -> int:
|
||||||
|
'''
|
||||||
|
Block until the winning handshake cancels this waiter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
death_started.set()
|
||||||
|
try:
|
||||||
|
await trio.sleep_forever()
|
||||||
|
except trio.Cancelled:
|
||||||
|
death_cancelled.set()
|
||||||
|
raise
|
||||||
|
|
||||||
|
result = await _spawn.wait_for_peer_or_proc_death(
|
||||||
|
ipc_server=SimpleNamespace(
|
||||||
|
wait_for_peer=wait_for_peer,
|
||||||
|
),
|
||||||
|
uid=uid,
|
||||||
|
proc_wait=wait_for_death,
|
||||||
|
proc_repr='handshake-proc',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == (peer_event, channel)
|
||||||
|
assert death_cancelled.is_set()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_child_death_wins_peer_handshake_race() -> None:
|
||||||
|
'''
|
||||||
|
Pre-handshake child death must fail startup instead of hanging.
|
||||||
|
|
||||||
|
Start the peer waiter first and leave it parked like
|
||||||
|
`IPCServer.wait_for_peer()` on an unset event. Return a distinctive
|
||||||
|
process status from the competing waiter, then prove the helper
|
||||||
|
cancels the handshake and raises `ActorFailure` with child identity,
|
||||||
|
status, and process diagnostics.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Control the death-first schedule with Trio events.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('dead-child', 'test')
|
||||||
|
handshake_started = trio.Event()
|
||||||
|
handshake_cancelled = trio.Event()
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Park until process death cancels this handshake waiter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
handshake_started.set()
|
||||||
|
try:
|
||||||
|
await trio.sleep_forever()
|
||||||
|
except trio.Cancelled:
|
||||||
|
handshake_cancelled.set()
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def wait_for_death() -> int:
|
||||||
|
'''
|
||||||
|
Report child death after handshake monitoring is active.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await handshake_started.wait()
|
||||||
|
return 23
|
||||||
|
|
||||||
|
with pytest.raises(ActorFailure) as exc_info:
|
||||||
|
await _spawn.wait_for_peer_or_proc_death(
|
||||||
|
ipc_server=SimpleNamespace(
|
||||||
|
wait_for_peer=wait_for_peer,
|
||||||
|
),
|
||||||
|
uid=uid,
|
||||||
|
proc_wait=wait_for_death,
|
||||||
|
proc_repr='dead-proc',
|
||||||
|
)
|
||||||
|
|
||||||
|
message: str = str(exc_info.value)
|
||||||
|
assert repr(uid) in message
|
||||||
|
assert 'died during boot' in message
|
||||||
|
assert '(rc=23)' in message
|
||||||
|
assert 'parent-handshake' in message
|
||||||
|
assert 'dead-proc' in message
|
||||||
|
assert handshake_cancelled.is_set()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_child_death_wins_simultaneous_boot_results() -> None:
|
||||||
|
'''
|
||||||
|
Observed process death must outrank a simultaneous handshake.
|
||||||
|
|
||||||
|
Hold both fake waits behind one barrier with cancellation shielding,
|
||||||
|
then release them together so both publish a committed result before
|
||||||
|
sibling cancellation takes effect. Because the child has exited
|
||||||
|
before receiving its `SpawnSpec`, bootstrap must raise `ActorFailure`
|
||||||
|
rather than return its briefly established channel.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Release both boot outcomes from one controlled barrier.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('simultaneous-child', 'test')
|
||||||
|
handshake_ready = trio.Event()
|
||||||
|
death_ready = trio.Event()
|
||||||
|
release = trio.Event()
|
||||||
|
peer_event = trio.Event()
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Publish a handshake despite sibling cancellation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
handshake_ready.set()
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
await release.wait()
|
||||||
|
return (peer_event, object())
|
||||||
|
|
||||||
|
async def wait_for_death() -> int:
|
||||||
|
'''
|
||||||
|
Publish process death despite sibling cancellation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
death_ready.set()
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
await release.wait()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def release_both() -> None:
|
||||||
|
'''
|
||||||
|
Open the barrier only after both waiters are parked.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await handshake_ready.wait()
|
||||||
|
await death_ready.wait()
|
||||||
|
release.set()
|
||||||
|
|
||||||
|
async with trio.open_nursery() as nursery:
|
||||||
|
nursery.start_soon(release_both)
|
||||||
|
with pytest.raises(
|
||||||
|
ActorFailure,
|
||||||
|
match=r'simultaneous-child.*rc=0',
|
||||||
|
):
|
||||||
|
await _spawn.wait_for_peer_or_proc_death(
|
||||||
|
ipc_server=SimpleNamespace(
|
||||||
|
wait_for_peer=wait_for_peer,
|
||||||
|
),
|
||||||
|
uid=uid,
|
||||||
|
proc_wait=wait_for_death,
|
||||||
|
)
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('failing_waiter', ('handshake', 'death'))
|
||||||
|
def test_child_boot_race_preserves_waiter_error(
|
||||||
|
failing_waiter: str,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Waiter failures must retain their original exception identity.
|
||||||
|
|
||||||
|
Park the non-failing sibling and raise one unique error from either
|
||||||
|
the peer or process waiter. The helper's internal nursery must
|
||||||
|
cancel the sibling and re-raise that exact exception instead of
|
||||||
|
wrapping it in an `ExceptionGroup`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Trigger one selected waiter after its sibling starts.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('errored-child', 'test')
|
||||||
|
sibling_started = trio.Event()
|
||||||
|
wait_error = RuntimeError(f'{failing_waiter} failed')
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Raise or park according to the selected peer schedule.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
if failing_waiter == 'handshake':
|
||||||
|
await sibling_started.wait()
|
||||||
|
raise wait_error
|
||||||
|
|
||||||
|
sibling_started.set()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
async def wait_for_death() -> int:
|
||||||
|
'''
|
||||||
|
Raise or park according to the selected process schedule.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if failing_waiter == 'death':
|
||||||
|
await sibling_started.wait()
|
||||||
|
raise wait_error
|
||||||
|
|
||||||
|
sibling_started.set()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as exc_info:
|
||||||
|
await _spawn.wait_for_peer_or_proc_death(
|
||||||
|
ipc_server=SimpleNamespace(
|
||||||
|
wait_for_peer=wait_for_peer,
|
||||||
|
),
|
||||||
|
uid=uid,
|
||||||
|
proc_wait=wait_for_death,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value is wait_error
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trio_proc_cleans_failed_child_peer_event(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Death-first Trio startup must release provisional peer state.
|
||||||
|
|
||||||
|
Return one already-dead fake process while its server handshake
|
||||||
|
parks forever. The fake nursery proves the peer event exists before
|
||||||
|
provisional child publication. After `ActorFailure`, both that
|
||||||
|
exact event and the provisional child record must be gone so repeated
|
||||||
|
failed spawns cannot leak server state.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('dead-trio-child', 'test')
|
||||||
|
proc: trio.Process = MagicMock(spec=trio.Process)
|
||||||
|
proc.pid = 1234
|
||||||
|
proc.wait = AsyncMock(return_value=23)
|
||||||
|
proc.poll.return_value = 23
|
||||||
|
proc.__str__.return_value = 'dead-trio-proc'
|
||||||
|
|
||||||
|
class FakeServer:
|
||||||
|
'''
|
||||||
|
Hold the peer registry used during Trio child startup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._peer_connected: dict[
|
||||||
|
tuple[str, str],
|
||||||
|
trio.Event,
|
||||||
|
] = {}
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
self,
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Park like a child that never reaches its handshake.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
server = FakeServer()
|
||||||
|
|
||||||
|
class FakeNursery:
|
||||||
|
'''
|
||||||
|
Track provisional child publication and cleanup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._actor = SimpleNamespace(ipc_server=server)
|
||||||
|
self._children: dict[tuple[str, str], tuple] = {}
|
||||||
|
|
||||||
|
def _register_child(
|
||||||
|
self,
|
||||||
|
subactor: object,
|
||||||
|
proc: object,
|
||||||
|
portal: object|None,
|
||||||
|
) -> tuple[trio.Event, trio.Event, bool]:
|
||||||
|
'''
|
||||||
|
Require peer-event registration before child publication.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert uid in server._peer_connected
|
||||||
|
assert portal is None
|
||||||
|
self._children[uid] = (subactor, proc, portal)
|
||||||
|
return (trio.Event(), trio.Event(), False)
|
||||||
|
|
||||||
|
async def fake_open_process(
|
||||||
|
command: list[str],
|
||||||
|
**kwargs: object,
|
||||||
|
) -> trio.Process:
|
||||||
|
'''
|
||||||
|
Return a process whose death wins the bootstrap race.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert command
|
||||||
|
return proc
|
||||||
|
|
||||||
|
async def fake_wait_for_debugger(**kwargs: object) -> None:
|
||||||
|
'''
|
||||||
|
Keep hard-reap cleanup deterministic and non-interactive.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_trio.trio.lowlevel,
|
||||||
|
'open_process',
|
||||||
|
fake_open_process,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_trio.debug,
|
||||||
|
'maybe_wait_for_debugger',
|
||||||
|
fake_wait_for_debugger,
|
||||||
|
)
|
||||||
|
|
||||||
|
nursery = FakeNursery()
|
||||||
|
subactor = SimpleNamespace(
|
||||||
|
aid=tractor.msg.Aid(
|
||||||
|
name=uid[0],
|
||||||
|
uuid=uid[1],
|
||||||
|
),
|
||||||
|
loglevel=None,
|
||||||
|
pformat=lambda: 'dead-trio-child',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Run the full Trio backend through death-first cleanup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with pytest.raises(
|
||||||
|
ActorFailure,
|
||||||
|
match=r'dead-trio-child.*rc=23',
|
||||||
|
):
|
||||||
|
await _trio.trio_proc(
|
||||||
|
name=uid[0],
|
||||||
|
actor_nursery=nursery,
|
||||||
|
subactor=subactor,
|
||||||
|
errors={},
|
||||||
|
bind_addrs=[],
|
||||||
|
parent_addr=('127.0.0.1', 1616),
|
||||||
|
_runtime_vars={},
|
||||||
|
)
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
assert server._peer_connected == {}
|
||||||
|
assert nursery._children == {}
|
||||||
|
|
||||||
|
|
||||||
async def run_same_func_in_child(
|
async def run_same_func_in_child(
|
||||||
should_be_root: bool,
|
should_be_root: bool,
|
||||||
data: dict,
|
data: dict,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ from typing import (
|
||||||
import trio
|
import trio
|
||||||
from trio import TaskStatus
|
from trio import TaskStatus
|
||||||
|
|
||||||
|
from .._exceptions import ActorFailure
|
||||||
from ..devx import debug
|
from ..devx import debug
|
||||||
from tractor.runtime._state import (
|
from tractor.runtime._state import (
|
||||||
_runtime_vars,
|
_runtime_vars,
|
||||||
|
|
@ -50,6 +51,7 @@ from tractor.msg import types as msgtypes
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from tractor.ipc import (
|
from tractor.ipc import (
|
||||||
|
_server,
|
||||||
Channel,
|
Channel,
|
||||||
)
|
)
|
||||||
from tractor.runtime._supervise import ActorNursery
|
from tractor.runtime._supervise import ActorNursery
|
||||||
|
|
@ -81,6 +83,101 @@ else:
|
||||||
await trio.lowlevel.wait_readable(proc.sentinel)
|
await trio.lowlevel.wait_readable(proc.sentinel)
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_peer_or_proc_death(
|
||||||
|
ipc_server: _server.Server,
|
||||||
|
uid: tuple[str, str],
|
||||||
|
proc_wait: Callable[[], Awaitable[int]],
|
||||||
|
proc_repr: object = '',
|
||||||
|
) -> tuple[trio.Event, Channel]:
|
||||||
|
'''
|
||||||
|
Race a child handshake against process death during bootstrap.
|
||||||
|
|
||||||
|
A child can exit before connecting to its parent. Waiting only on
|
||||||
|
`IPCServer.wait_for_peer()` would then park its spawning task and
|
||||||
|
leave the dead process unreaped. Run both waits in one nursery and
|
||||||
|
let either completed result cancel its sibling.
|
||||||
|
|
||||||
|
Return the normal peer event and channel when the handshake wins.
|
||||||
|
Raise `ActorFailure` with the process status when death wins.
|
||||||
|
|
||||||
|
Adapted from goodboy's Claude Code-assisted implementation in
|
||||||
|
commit `3b0724eba85b4014170ed95773e1e41a60d5c513`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
handshake: tuple[trio.Event, Channel]|None = None
|
||||||
|
handshake_error: BaseException|None = None
|
||||||
|
returncode: int|None = None
|
||||||
|
death_error: BaseException|None = None
|
||||||
|
|
||||||
|
async def wait_for_handshake() -> None:
|
||||||
|
'''
|
||||||
|
Publish a connected peer before cancelling the death waiter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
nonlocal handshake
|
||||||
|
nonlocal handshake_error
|
||||||
|
try:
|
||||||
|
handshake = await ipc_server.wait_for_peer(uid)
|
||||||
|
except trio.Cancelled:
|
||||||
|
if (
|
||||||
|
returncode is not None
|
||||||
|
or
|
||||||
|
death_error is not None
|
||||||
|
):
|
||||||
|
log.debug(
|
||||||
|
'Peer-handshake waiter cancelled after '
|
||||||
|
f'process wait completed for {uid!r}'
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except BaseException as exc:
|
||||||
|
handshake_error = exc
|
||||||
|
nursery.cancel_scope.cancel()
|
||||||
|
|
||||||
|
async def wait_for_death() -> None:
|
||||||
|
'''
|
||||||
|
Publish child exit before cancelling the handshake waiter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
nonlocal returncode
|
||||||
|
nonlocal death_error
|
||||||
|
try:
|
||||||
|
returncode = await proc_wait()
|
||||||
|
except trio.Cancelled:
|
||||||
|
if (
|
||||||
|
handshake is not None
|
||||||
|
or
|
||||||
|
handshake_error is not None
|
||||||
|
):
|
||||||
|
log.debug(
|
||||||
|
'Process-death waiter cancelled after '
|
||||||
|
f'peer wait completed for {uid!r}'
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except BaseException as exc:
|
||||||
|
death_error = exc
|
||||||
|
nursery.cancel_scope.cancel()
|
||||||
|
|
||||||
|
async with trio.open_nursery() as nursery:
|
||||||
|
nursery.start_soon(wait_for_handshake)
|
||||||
|
nursery.start_soon(wait_for_death)
|
||||||
|
|
||||||
|
if handshake_error is not None:
|
||||||
|
raise handshake_error
|
||||||
|
if death_error is not None:
|
||||||
|
raise death_error
|
||||||
|
|
||||||
|
if returncode is not None:
|
||||||
|
raise ActorFailure(
|
||||||
|
f'Sub-actor {uid!r} died during boot '
|
||||||
|
f'(rc={returncode!r}) before completing '
|
||||||
|
f'parent-handshake.\n'
|
||||||
|
f' proc: {proc_repr}'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert handshake is not None
|
||||||
|
return handshake
|
||||||
|
|
||||||
|
|
||||||
def try_set_start_method(
|
def try_set_start_method(
|
||||||
key: SpawnMethodKey
|
key: SpawnMethodKey
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ from tractor.msg import (
|
||||||
from ._spawn import (
|
from ._spawn import (
|
||||||
hard_kill,
|
hard_kill,
|
||||||
soft_kill,
|
soft_kill,
|
||||||
|
wait_for_peer_or_proc_death,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -81,23 +82,24 @@ async def trio_proc(
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
Create a new ``Process`` using a "spawn method" as (configured using
|
Create a new ``Process`` using a "spawn method" as (configured
|
||||||
``try_set_start_method()``).
|
using ``try_set_start_method()``).
|
||||||
|
|
||||||
This routine should be started in a actor runtime task and the logic
|
This routine should be started in a actor runtime task and the
|
||||||
here is to be considered the core supervision strategy.
|
logic here is to be considered the core supervision strategy.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
spawn_cmd = [
|
spawn_cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
# Hardcode this (instead of using ``_child.__name__`` to avoid a
|
# Hardcode this (instead of using ``_child.__name__`` to
|
||||||
# double import warning: https://stackoverflow.com/a/45070583
|
# avoid a double import warning:
|
||||||
|
# https://stackoverflow.com/a/45070583
|
||||||
"tractor._child",
|
"tractor._child",
|
||||||
# We provide the child's unique identifier on this exec/spawn
|
# We provide the child's unique identifier on this exec/spawn
|
||||||
# line for debugging purposes when viewing the process tree from
|
# line for debugging purposes when viewing the process tree
|
||||||
# the OS; it otherwise can be passed via the parent channel if
|
# from the OS; it otherwise can be passed via the parent
|
||||||
# we prefer in the future (for privacy).
|
# channel if we prefer in the future (for privacy).
|
||||||
"--uid",
|
"--uid",
|
||||||
# TODO, how to pass this over "wire" encodings like
|
# TODO, how to pass this over "wire" encodings like
|
||||||
# cmdline args?
|
# cmdline args?
|
||||||
|
|
@ -115,20 +117,34 @@ async def trio_proc(
|
||||||
]
|
]
|
||||||
# Tell child to run in guest mode on top of ``asyncio`` loop
|
# Tell child to run in guest mode on top of ``asyncio`` loop
|
||||||
if infect_asyncio:
|
if infect_asyncio:
|
||||||
spawn_cmd.append("--asyncio")
|
spawn_cmd.append('--asyncio')
|
||||||
|
|
||||||
cancelled_during_spawn: bool = False
|
cancelled_during_spawn: bool = False
|
||||||
proc: trio.Process|None = None
|
proc: trio.Process|None = None
|
||||||
ipc_server: _server.Server = actor_nursery._actor.ipc_server
|
ipc_server: _server.Server = actor_nursery._actor.ipc_server
|
||||||
|
peer_event: trio.Event|None = None
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
proc: trio.Process = await trio.lowlevel.open_process(spawn_cmd, **proc_kwargs)
|
proc: trio.Process = await trio.lowlevel.open_process(
|
||||||
|
spawn_cmd,
|
||||||
|
**proc_kwargs,
|
||||||
|
)
|
||||||
log.runtime(
|
log.runtime(
|
||||||
f'Started new child subproc\n'
|
f'Started new child subproc\n'
|
||||||
f'(>\n'
|
f'(>\n'
|
||||||
f' |_{proc}\n'
|
f' |_{proc}\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# `ActorNursery.cancel()` may inspect this event as soon
|
||||||
|
# as the provisional child is published below. Register
|
||||||
|
# the event synchronously before
|
||||||
|
# `wait_for_peer_or_proc_death()` opens its nursery and
|
||||||
|
# checkpoints.
|
||||||
|
peer_event = ipc_server._peer_connected.setdefault(
|
||||||
|
subactor.aid.uid,
|
||||||
|
trio.Event(),
|
||||||
|
)
|
||||||
|
|
||||||
# No `Portal` exists until the IPC handshake returns
|
# No `Portal` exists until the IPC handshake returns
|
||||||
# `chan`. Replace this provisional entry with
|
# `chan`. Replace this provisional entry with
|
||||||
# `Portal(chan)` below.
|
# `Portal(chan)` below.
|
||||||
|
|
@ -152,8 +168,11 @@ async def trio_proc(
|
||||||
# wait for actor to spawn and connect back to us
|
# wait for actor to spawn and connect back to us
|
||||||
# channel should have handshake completed by the
|
# channel should have handshake completed by the
|
||||||
# local actor by the time we get a ref to it
|
# local actor by the time we get a ref to it
|
||||||
event, chan = await ipc_server.wait_for_peer(
|
event, chan = await wait_for_peer_or_proc_death(
|
||||||
subactor.aid.uid
|
ipc_server=ipc_server,
|
||||||
|
uid=subactor.aid.uid,
|
||||||
|
proc_wait=proc.wait,
|
||||||
|
proc_repr=proc,
|
||||||
)
|
)
|
||||||
|
|
||||||
except trio.Cancelled:
|
except trio.Cancelled:
|
||||||
|
|
@ -269,21 +288,21 @@ async def trio_proc(
|
||||||
# to hold off on relaying SIGINT until that child
|
# to hold off on relaying SIGINT until that child
|
||||||
# is complete.
|
# is complete.
|
||||||
# https://github.com/goodboy/tractor/issues/320
|
# https://github.com/goodboy/tractor/issues/320
|
||||||
# -[ ] we need to handle non-root parent-actors specially
|
# -[ ] we need to handle non-root parent-actors
|
||||||
# by somehow determining if a child is in debug and then
|
# specially by somehow determining if a child is in
|
||||||
# avoiding cancel/kill of said child by this
|
# debug and then avoiding cancel/kill of said child
|
||||||
# (intermediary) parent until such a time as the root says
|
# by this (intermediary) parent until such a time as
|
||||||
# the pdb lock is released and we are good to tear down
|
# the root says the pdb lock is released and we are
|
||||||
# (our children)..
|
# good to tear down (our children)..
|
||||||
#
|
#
|
||||||
# -[ ] so maybe something like this where we try to
|
# -[ ] so maybe something like this where we try to
|
||||||
# acquire the lock and get notified of who has it,
|
# acquire the lock and get notified of who has
|
||||||
# check that uid against our known children?
|
# it, check that uid against our known children?
|
||||||
# this_uid: tuple[str, str] = current_actor().uid
|
# this_uid: tuple[str, str] = current_actor().uid
|
||||||
# await debug.acquire_debug_lock(this_uid)
|
# await debug.acquire_debug_lock(this_uid)
|
||||||
|
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
log.cancel(f"Attempting to hard kill {proc}")
|
log.cancel(f'Attempting to hard kill {proc}')
|
||||||
await hard_kill(
|
await hard_kill(
|
||||||
proc,
|
proc,
|
||||||
# NOTE, pass through so post-SIGKILL we
|
# NOTE, pass through so post-SIGKILL we
|
||||||
|
|
@ -302,10 +321,19 @@ async def trio_proc(
|
||||||
subactor=subactor,
|
subactor=subactor,
|
||||||
)
|
)
|
||||||
|
|
||||||
log.debug(f"Joined {proc}")
|
log.debug(f'Joined {proc}')
|
||||||
else:
|
else:
|
||||||
log.warning('Nursery cancelled before sub-proc started')
|
log.warning('Nursery cancelled before sub-proc started')
|
||||||
|
|
||||||
|
if (
|
||||||
|
peer_event is not None
|
||||||
|
and
|
||||||
|
ipc_server._peer_connected.get(
|
||||||
|
subactor.aid.uid,
|
||||||
|
) is peer_event
|
||||||
|
):
|
||||||
|
ipc_server._peer_connected.pop(subactor.aid.uid)
|
||||||
|
|
||||||
if not cancelled_during_spawn:
|
if not cancelled_during_spawn:
|
||||||
# pop child entry to indicate we no longer managing this
|
# pop child entry to indicate we no longer managing this
|
||||||
# subactor
|
# subactor
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue