Skip to content

Commit 9993db0

Browse files
committed
Scrub inherited FDs in fork-child prelude
Implements fix-direction (1)/blunt-close-all-FDs from b71705b (`subint_forkserver` nested-cancel hang diag), targeting the multi-level cancel-cascade deadlock in `test_nested_multierrors[subint_forkserver]`. The diagnosis doc voted for surgical FD cleanup via `actor.ipc_server` handle as the cleanest approach, but going blunt is actually the right call: after `os.fork()`, the child immediately enters `_actor_child_main()` which opens its OWN IPC sockets / wakeup-fd / epoll-fd / etc. — none of the parent's FDs are needed. Closing everything except stdio is safe AND defends against future listener/IPC additions to the parent inheriting silently into children. Deats, - new `_close_inherited_fds(keep={0,1,2}) -> int` helper. Linux fast-path enumerates `/proc/self/fd`; POSIX fallback uses `RLIMIT_NOFILE` range. Matches the stdlib `subprocess._posixsubprocess.close_fds` strategy. Returns close-count for sanity logging - wire into `fork_from_worker_thread._worker()`'s post-fork child prelude — runs immediately after the pid-pipe `os.close(rfd/wfd)`, before the user `child_target` callable executes - docstring cross-refs the diagnosis doc + spells out the FD-inheritance-cascade mechanism and why the close-all approach is safe for our spawn shape Validation pending: re-run `test_nested_multierrors[subint_forkserver]` to confirm the deadlock is gone. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code
1 parent 35da808 commit 9993db0

1 file changed

Lines changed: 68 additions & 1 deletion

File tree

tractor/spawn/_subint_forkserver.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,69 @@
195195
_has_subints: bool = False
196196

197197

198+
def _close_inherited_fds(
199+
keep: frozenset[int] = frozenset({0, 1, 2}),
200+
) -> int:
201+
'''
202+
Close every open file descriptor in the current process
203+
EXCEPT those in `keep` (default: stdio only).
204+
205+
Intended as the first thing a post-`os.fork()` child runs
206+
after closing any communication pipes it knows about. This
207+
is the fork-child FD hygiene discipline that
208+
`subprocess.Popen(close_fds=True)` applies by default for
209+
its exec-based children, but which we have to implement
210+
ourselves because our `fork_from_worker_thread()` primitive
211+
deliberately does NOT exec.
212+
213+
Why it matters
214+
--------------
215+
Without this, a forkserver-spawned subactor inherits the
216+
parent actor's IPC listener sockets, trio-epoll fd, trio
217+
wakeup-pipe, peer-channel sockets, etc. If that subactor
218+
then itself forkserver-spawns a grandchild, the grandchild
219+
inherits the FDs transitively from *both* its direct
220+
parent AND the root actor — IPC message routing becomes
221+
ambiguous and the cancel cascade deadlocks. See
222+
`ai/conc-anal/subint_forkserver_test_cancellation_leak_issue.md`
223+
for the full diagnosis + the empirical repro.
224+
225+
Fresh children will open their own IPC sockets via
226+
`_actor_child_main()`, so they don't need any of the
227+
parent's FDs.
228+
229+
Returns the count of fds that were successfully closed —
230+
useful for sanity-check logging at callsites.
231+
232+
'''
233+
# Enumerate open fds via `/proc/self/fd` on Linux (the fast +
234+
# precise path); fall back to `RLIMIT_NOFILE` range close on
235+
# other platforms. Matches stdlib
236+
# `subprocess._posixsubprocess.close_fds` strategy.
237+
try:
238+
fd_names: list[str] = os.listdir('/proc/self/fd')
239+
candidates: list[int] = [
240+
int(n) for n in fd_names if n.isdigit()
241+
]
242+
except (FileNotFoundError, PermissionError):
243+
import resource
244+
soft, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
245+
candidates = list(range(3, soft))
246+
247+
closed: int = 0
248+
for fd in candidates:
249+
if fd in keep:
250+
continue
251+
try:
252+
os.close(fd)
253+
closed += 1
254+
except OSError:
255+
# fd was already closed (race with listdir) or
256+
# otherwise unclosable — either is fine.
257+
pass
258+
return closed
259+
260+
198261
def _format_child_exit(
199262
status: int,
200263
) -> str:
@@ -302,9 +365,13 @@ def _worker() -> None:
302365
pid: int = os.fork()
303366
if pid == 0:
304367
# CHILD: close the pid-pipe ends (we don't use
305-
# them here), run the user callable if any, exit.
368+
# them here), then scrub ALL other inherited FDs
369+
# so the child starts with a clean slate
370+
# (stdio-only). Critical for multi-level spawn
371+
# trees — see `_close_inherited_fds()` docstring.
306372
os.close(rfd)
307373
os.close(wfd)
374+
_close_inherited_fds()
308375
rc: int = 0
309376
if child_target is not None:
310377
try:

0 commit comments

Comments
 (0)