mirror of
https://github.com/angr/angr
synced 2026-08-17 12:23:11 -04:00
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
119 lines
4.7 KiB
Python
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__])
|