Skip to content

Commit e312a68

Browse files
committed
Bound peer-clear wait in async_main finally
Fifth diagnostic pass pinpointed the hang to `async_main`'s finally block — every stuck actor reaches `FINALLY ENTER` but never `RETURNING`. Specifically `await ipc_server.wait_for_no_more_ peers()` never returns when a peer-channel handler is stuck: the `_no_more_peers` Event is set only when `server._peers` empties, and stuck handlers keep their channels registered. Wrap the call in `trio.move_on_after(3.0)` + a warning-log on timeout that records the still- connected peer count. 3s is enough for any graceful cancel-ack round-trip; beyond that we're in bug territory and need to proceed with local teardown so the parent's `_ForkedProc.wait()` can unblock. Defensive-in-depth regardless of the underlying bug — a local finally shouldn't block on remote cooperation forever. Verified: with this fix, ALL 15 actors reach `async_main: RETURNING` (up from 10/15 before). Test still hangs past 45s though — there's at least one MORE unbounded wait downstream of `async_main`. Candidates enumerated in the doc update (`open_root_actor` finally / `actor.cancel()` internals / trio.run bg tasks / `_serve_ipc_eps` finally). Skip-mark stays on `test_nested_multierrors[subint_forkserver]`. Also updates `subint_forkserver_test_cancellation_leak_issue.md` with the new pinpoint + summary of the 6-item investigation win list: 1. FD hygiene fix (`_close_inherited_fds`) — orphan-SIGINT closed 2. pidfd-based `_ForkedProc.wait` — cancellable 3. `_parent_chan_cs` wiring — shielded parent-chan loop now breakable 4. `wait_for_no_more_peers` bound — THIS commit 5. Ruled-out hypotheses: tree-kill missing, stuck socket recv, capture-pipe fill (all wrong) 6. Remaining unknown: at least one more unbounded wait in the teardown cascade above `async_main` (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code
1 parent 4d05554 commit e312a68

2 files changed

Lines changed: 106 additions & 1 deletion

File tree

ai/conc-anal/subint_forkserver_test_cancellation_leak_issue.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,93 @@ asymmetric. Not purely depth-determined. Some race
635635
condition in nursery teardown when multiple
636636
siblings error simultaneously.
637637

638+
## Update — 2026-04-23 (late, probe iteration 3): hang pinpointed to `wait_for_no_more_peers()`
639+
640+
Further DIAGDEBUG at every milestone in `async_main`
641+
(runtime UP / EXITED service_tn / EXITED root_tn /
642+
FINALLY ENTER / RETURNING) plus `_ForkedProc.wait`
643+
ENTER/RETURNED per-pidfd. Result:
644+
645+
**Every stuck actor reaches `async_main: FINALLY
646+
ENTER` but NOT `async_main: RETURNING`.**
647+
648+
That isolates the hang to a specific await in
649+
`async_main`'s finally block at
650+
`tractor/runtime/_runtime.py:1837+`. The suspect:
651+
652+
```python
653+
# Ensure all peers (actors connected to us as clients) are finished
654+
if ipc_server := actor.ipc_server and ipc_server.has_peers(check_chans=True):
655+
...
656+
await ipc_server.wait_for_no_more_peers() # ← UNBOUNDED, blocks forever
657+
```
658+
659+
`_no_more_peers` is an `Event` set only when
660+
`server._peers` empties (see
661+
`ipc/_server.py:526-530`). If ANY peer-handler is
662+
stuck (the 5 unclosed loops from the earlier pass),
663+
it keeps its channel in `server._peers`, so the
664+
event never fires, so the wait hangs.
665+
666+
### Applied fix (partial, landed as defensive-in-depth)
667+
668+
`tractor/runtime/_runtime.py:1981`
669+
`wait_for_no_more_peers()` call now wrapped in
670+
`trio.move_on_after(3.0)` + a warning log when the
671+
timeout fires. Commented with the full rationale.
672+
673+
**Verified:** with this fix, ALL 15 actors reach
674+
`async_main: RETURNING` cleanly (up from 10/15
675+
reaching end before).
676+
677+
**Unfortunately:** the test still hangs past 45s
678+
total — meaning there's YET ANOTHER unbounded wait
679+
downstream of `async_main`. The bounded
680+
`wait_for_no_more_peers` unblocks one level, but
681+
the cascade has another level above it.
682+
683+
### Candidates for the remaining hang
684+
685+
1. `open_root_actor`'s own finally / post-
686+
`async_main` flow in `_root.py` — specifically
687+
`await actor.cancel(None)` which has its own
688+
internal waits.
689+
2. The `trio.run()` itself doesn't return even
690+
after the root task completes because trio's
691+
nursery still has background tasks running.
692+
3. Maybe `_serve_ipc_eps`'s finally has an await
693+
that blocks when peers aren't clearing.
694+
695+
### Current stance
696+
697+
- Defensive `wait_for_no_more_peers` bound landed
698+
(good hygiene regardless). Revealing a real
699+
deadlock-avoidance gap in tractor's cleanup.
700+
- Test still hangs → skip-mark restored on
701+
`test_nested_multierrors[subint_forkserver]`.
702+
- The full chain of unbounded waits needs another
703+
session of drilling, probably at
704+
`open_root_actor` / `actor.cancel` level.
705+
706+
### Summary of this investigation's wins
707+
708+
1. **FD hygiene fix** (`_close_inherited_fds`) —
709+
correct, closed orphan-SIGINT sibling issue.
710+
2. **pidfd-based `_ForkedProc.wait`** — cancellable,
711+
matches trio_proc pattern.
712+
3. **`_parent_chan_cs` wiring**
713+
`Actor.cancel()` now breaks the shielded parent-
714+
chan `process_messages` loop.
715+
4. **`wait_for_no_more_peers` bounded**
716+
prevents the actor-level finally hang.
717+
5. **Ruled-out hypotheses:** tree-kill missing
718+
(wrong), stuck socket recv (wrong), capture-
719+
pipe fill (wrong).
720+
6. **Pinpointed remaining unknown:** at least one
721+
more unbounded wait in the teardown cascade
722+
above `async_main`. Concrete candidates
723+
enumerated above.
724+
638725
## Stopgap (landed)
639726

640727
`test_nested_multierrors` skip-marked under

tractor/runtime/_runtime.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1973,7 +1973,25 @@ async def async_main(
19731973
f' {pformat(ipc_server._peers)}'
19741974
)
19751975
log.runtime(teardown_report)
1976-
await ipc_server.wait_for_no_more_peers()
1976+
# NOTE: bound the peer-clear wait — otherwise if any
1977+
# peer-channel handler is stuck (e.g. never got its
1978+
# cancel propagated due to a runtime bug), this wait
1979+
# blocks forever and deadlocks the whole actor-tree
1980+
# teardown cascade. 3s is enough for any graceful
1981+
# cancel-ack round-trip; beyond that we're in bug
1982+
# territory and need to proceed with local teardown
1983+
# so the parent's `_ForkedProc.wait()` can unblock.
1984+
# See `ai/conc-anal/
1985+
# subint_forkserver_test_cancellation_leak_issue.md`
1986+
# for the full diagnosis.
1987+
with trio.move_on_after(3.0) as _peers_cs:
1988+
await ipc_server.wait_for_no_more_peers()
1989+
if _peers_cs.cancelled_caught:
1990+
teardown_report += (
1991+
f'-> TIMED OUT waiting for peers to clear '
1992+
f'({len(ipc_server._peers)} still connected)\n'
1993+
)
1994+
log.warning(teardown_report)
19771995

19781996
teardown_report += (
19791997
'-]> all peer channels are complete.\n'

0 commit comments

Comments
 (0)