diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 7adb6791..15f317c9 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -116,6 +116,41 @@ jobs: PYTHON_TEST_VERSION: ${{ matrix.python_version }} run: python3 -m pytest tests -n auto -vvv + test_attaching_to_uv_interpreters: + needs: [build_wheels] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python_version: ["3.13"] + steps: + - uses: actions/checkout@v5 + - uses: actions/download-artifact@v5 + with: + name: "manylinux_x86_64-wheels" + path: dist + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: latest + python-version: ${{ matrix.python_version }} + activate-environment: true + - name: Set up dependencies + run: | + sudo apt-get update + sudo apt-get install -qy gdb + - name: Install Python dependencies + run: | + uv pip install -r requirements-test.txt + uv pip install --no-index --find-links=dist/ --only-binary=pystack pystack + - name: Disable ptrace security restrictions + run: | + echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope + - name: Run pytest + env: + PYTHON_TEST_VERSION: ${{ matrix.python_version }} + run: python3 -m pytest tests -n auto -vvv + test_wheels: needs: [build_wheels] runs-on: ubuntu-22.04 diff --git a/news/258.bugfix.rst b/news/258.bugfix.rst new file mode 100644 index 00000000..241682a6 --- /dev/null +++ b/news/258.bugfix.rst @@ -0,0 +1 @@ +Fix handling of duplicate ``_PyRuntime`` symbols when ctypes mmaps a statically linked Python interpreter's binary in order to create a trampoline. Previously this led to "Invalid address in remote process" errors. diff --git a/src/pystack/_pystack/process.cpp b/src/pystack/_pystack/process.cpp index 52ec0099..56e32bae 100644 --- a/src/pystack/_pystack/process.cpp +++ b/src/pystack/_pystack/process.cpp @@ -80,7 +80,7 @@ operator<<(std::ostream& out, const ParsedPyVersion& version) // Use a temporary stringstream in case `out` is using hex or showbase std::ostringstream oss; oss << version.major << "." << version.minor << "." << version.patch; - if (version.release_level) { + if (version.release_level[0]) { oss << version.release_level << version.serial; } @@ -97,7 +97,7 @@ parsePyVersionHex(uint64_t version, ParsedPyVersion& parsed) int level = (version >> 4) & 0x0F; int count = (version >> 0) & 0x0F; - const char* level_str = nullptr; + const char* level_str = "(unknown release level)"; if (level == 0xA) { level_str = "a"; } else if (level == 0xB) { diff --git a/src/pystack/_pystack/unwinder.cpp b/src/pystack/_pystack/unwinder.cpp index 2804c128..3ef8b938 100644 --- a/src/pystack/_pystack/unwinder.cpp +++ b/src/pystack/_pystack/unwinder.cpp @@ -378,7 +378,7 @@ module_callback( module_arg->addr = addr; LOG(INFO) << "Symbol '" << sname << "' found at address " << std::hex << std::showbase << addr; - break; + return DWARF_CB_ABORT; } } return DWARF_CB_OK; @@ -389,7 +389,7 @@ AbstractUnwinder::getAddressforSymbol(const std::string& symbol, const std::stri { LOG(DEBUG) << "Trying to find address for symbol " << symbol; ModuleArg arg = {symbol.c_str(), modulename.c_str(), 0}; - if (dwfl_getmodules(Dwfl(), module_callback, &arg, 0) != 0) { + if (dwfl_getmodules(Dwfl(), module_callback, &arg, 0) == -1) { throw UnwinderError("Failed to fetch modules!"); } LOG(DEBUG) << "Address for symbol " << symbol << " resolved to: " << std::hex << std::showbase diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..6cae3f3e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import shutil + +import pytest + + +def pytest_sessionstart(session): + if not shutil.which("gcore"): + pytest.exit("gcore not found (you probably forgot to install gdb)") diff --git a/tests/integration/ctypes_program.py b/tests/integration/ctypes_program.py new file mode 100644 index 00000000..5d174e66 --- /dev/null +++ b/tests/integration/ctypes_program.py @@ -0,0 +1,24 @@ +import ctypes +import sys +import time + + +def first_func(): + second_func() + + +def second_func(): + third_func() + + +def third_func(): + # Trigger libffi to re-import the Python binary + global gil_check + gil_check = ctypes.CFUNCTYPE(ctypes.c_int)(ctypes.CDLL(None).PyGILState_Check) + + with open(sys.argv[1], "w") as fifo: + fifo.write("ready") + time.sleep(1000) + + +first_func() diff --git a/tests/integration/test_shenanigans.py b/tests/integration/test_shenanigans.py new file mode 100644 index 00000000..f01b0a05 --- /dev/null +++ b/tests/integration/test_shenanigans.py @@ -0,0 +1,35 @@ +import sys +from pathlib import Path + +from pystack.engine import get_process_threads +from tests.utils import spawn_child_process + +TEST_DUPLICATE_SYMBOLS_FILE = Path(__file__).parent / "ctypes_program.py" + + +def test_duplicate_pyruntime_symbol_handling(tmpdir): + """Test that pystack correctly handles duplicate _PyRuntime symbols. + + This can occur when ctypes uses libffi to dlopen the Python binary + in order to create a trampoline (which it only does if the Python binary + was statically linked against libpython). + """ + # GIVEN + with spawn_child_process( + sys.executable, TEST_DUPLICATE_SYMBOLS_FILE, tmpdir + ) as child_process: + # WHEN + threads = list(get_process_threads(child_process.pid, stop_process=True)) + + # THEN + # We should have successfully resolved threads without "Invalid address" errors + assert threads is not None + assert len(threads) > 0 + + # Verify we can get stack traces (which requires correct _PyRuntime) + for thread in threads: + # Just ensure we can get frames without crashing + frames = list(thread.frames) + # The main thread should have at least one frame + if thread.tid == child_process.pid: + assert len(frames) > 0