angr/tests/utils/test_mp_stdio.py
Fish bc72b9e1a6
Some checks failed
CI / Rust Check (push) Failing after 6s
CI / rust_test (ubuntu-latest) (push) Failing after 9s
Test with coverage / Build (push) Failing after 8s
Test with coverage / Test (push) Has been skipped
Test with coverage / Test-1 (push) Has been skipped
Test with coverage / Test-2 (push) Has been skipped
Test with coverage / Test-3 (push) Has been skipped
Test with coverage / Test-4 (push) Has been skipped
Test with coverage / Test-5 (push) Has been skipped
Test with coverage / Test-6 (push) Has been skipped
Test with coverage / Test-7 (push) Has been skipped
Test with coverage / Test-8 (push) Has been skipped
Test with coverage / Test-9 (push) Has been skipped
Test with coverage / Test Rust packages (push) Failing after 6s
Test with coverage / Report (push) Has been skipped
CI / ci (push) Has been cancelled
CI / Test installation (macos-26, py3.12) (push) Has been cancelled
CI / Test installation (ubuntu-24.04, py3.14) (push) Has been cancelled
CI / Test installation (windows-2025, py3.12) (push) Has been cancelled
CI / rust_test (macos-latest) (push) Has been cancelled
CI / rust_test (windows-latest) (push) Has been cancelled
MCP: Protect stdio from forked workers. (#6717)
2026-07-27 03:01:56 -07:00

119 lines
4.7 KiB
Python

# pylint:disable=no-self-use
"""
Regression tests for angr.utils.mp.protect_stdio_from_forked_children.
An MCP server (or any other host) that speaks a protocol over stdin/stdout keeps a thread parked in a blocking read
on ``sys.stdin``. CPython's ``BufferedReader`` holds its internal lock across that blocking read, so a ``fork()``
performed from a *different* thread produces a child that inherits the lock in a permanently locked state. The very
first thing ``multiprocessing.process.BaseProcess._bootstrap()`` does is ``util._close_stdin()``, which closes
``sys.stdin`` -- and hangs forever. Every angr analysis that takes a ``workers=N`` argument uses the ``fork`` start
method on Linux (see :func:`angr.utils.mp.mp_context`), so they all deadlock in that situation.
These tests run in a subprocess because they need to own stdin/stdout and to install a process-global
``os.register_at_fork`` hook.
"""
from __future__ import annotations
import os
import subprocess
import sys
import textwrap
import pytest
# language=python
_CHILD_SCRIPT = textwrap.dedent(
"""
import io, multiprocessing, os, sys, threading, time
if {install_fix}:
from angr.utils.mp import protect_stdio_from_forked_children
protect_stdio_from_forked_children()
def stdin_reader():
# this is what mcp.server.stdio.stdio_server() does
io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace").readline()
threading.Thread(target=stdin_reader, daemon=True).start()
time.sleep(0.5) # let it settle into the blocking read, holding the buffer lock
def child():
# both of these would land on the protocol channel without the fix
print("CHILD_STDOUT")
os.write(1, b"CHILD_FD1\\n")
sys.stderr.write("CHILD_RAN\\n")
sys.stderr.flush()
ctx = multiprocessing.get_context("fork")
procs = [ctx.Process(target=child, daemon=True) for _ in range(2)]
for p in procs:
p.start()
for p in procs:
p.join(timeout=5)
alive = [p.pid for p in procs if p.is_alive()]
for p in procs:
if p.is_alive():
p.kill()
sys.stderr.write("ALIVE=%d\\n" % len(alive))
sys.stderr.flush()
"""
)
def _run(install_fix: bool) -> subprocess.CompletedProcess[str]:
# The reader thread must still be *inside* the blocking read when fork() happens -- that is the steady state of
# a stdio JSON-RPC server between requests. So hand the child a pipe and keep the write end open here;
# subprocess.run(stdin=PIPE) would close it immediately, the read would return EOF, and the lock would be
# released before the fork.
read_fd, write_fd = os.pipe()
try:
return subprocess.run(
[sys.executable, "-c", _CHILD_SCRIPT.format(install_fix=install_fix)],
stdin=read_fd,
capture_output=True,
text=True,
timeout=120,
check=False,
)
finally:
os.close(read_fd)
os.close(write_fd)
@pytest.mark.skipif(not hasattr(os, "fork"), reason="requires fork()")
class TestProtectStdioFromForkedChildren:
"""Tests for protect_stdio_from_forked_children()."""
def test_forked_workers_deadlock_without_the_guard(self):
"""Without the guard, fork()ed children wedge in multiprocessing's _close_stdin()."""
result = _run(install_fix=False)
assert "ALIVE=2" in result.stderr, f"expected both children to hang; stderr={result.stderr!r}"
assert "CHILD_RAN" not in result.stderr
# they never even get as far as running the target, so nothing reaches stdout either
assert result.stdout == ""
def test_forked_workers_run_with_the_guard(self):
"""With the guard installed, the children start normally and finish."""
result = _run(install_fix=True)
assert "ALIVE=0" in result.stderr, f"children did not finish; stderr={result.stderr!r}"
assert result.stderr.count("CHILD_RAN") == 2
def test_child_output_is_kept_off_of_stdout(self):
"""The parent's stdout is a protocol channel; nothing a child writes may reach it."""
result = _run(install_fix=True)
assert result.stdout == "", f"child output leaked onto stdout: {result.stdout!r}"
assert "CHILD_STDOUT" in result.stderr
assert "CHILD_FD1" in result.stderr
def test_is_idempotent(self):
"""Calling it repeatedly must not stack up fork hooks."""
from angr.utils.mp import protect_stdio_from_forked_children # pylint:disable=import-outside-toplevel
protect_stdio_from_forked_children()
protect_stdio_from_forked_children()
protect_stdio_from_forked_children()
if __name__ == "__main__":
pytest.main([__file__])