From bba76d43968e625f58437485f64ccf82b947ef7b Mon Sep 17 00:00:00 2001 From: Matt Wozniski Date: Fri, 3 Oct 2025 18:17:16 -0400 Subject: [PATCH 1/4] tests: Fail if gcore isn't available Previously the test suite would hang instead of failing, with no indication of what had gone wrong. Signed-off-by: Matt Wozniski --- tests/conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6cae3f3 --- /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)") From 24e4fc1a47fa4ae77faa67ee3fdf3d926e29e1f1 Mon Sep 17 00:00:00 2001 From: Matt Wozniski Date: Fri, 3 Oct 2025 17:38:57 -0400 Subject: [PATCH 2/4] ci: Test with uv interpreters `uv` uses the `python-standalone` project for its interpreters, which use an unusual and not fully supported build workflow. Add a test to our CI to ensure that pytest works properly with uv's interpreters. Signed-off-by: Matt Wozniski --- .github/workflows/build_wheels.yml | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 7adb679..15f317c 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 From e63ec6395c5b33b0a2914998bad45a1d1942eeef Mon Sep 17 00:00:00 2001 From: Matt Wozniski Date: Wed, 1 Oct 2025 17:19:24 -0400 Subject: [PATCH 3/4] Fix a debugging log message We were incorrectly printing that a 3.13.7 process was 3.13.70 because we would always print the serial number (always 0 for a final release) whenever the release level was not a null string - and for final releases, we had it set to the empty string. Fix this to check for empty strings, instead, and ensure we never set it to a null string, instead using a recognizable sentinel for invalid release levels. Signed-off-by: Matt Wozniski --- src/pystack/_pystack/process.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pystack/_pystack/process.cpp b/src/pystack/_pystack/process.cpp index 52ec009..56e32ba 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) { From 055c0612beafe5c2da849d44cf6f392392842110 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 29 Sep 2025 16:44:31 +0100 Subject: [PATCH 4/4] Fix handling duplicate symbols when libraries dlopen Python When libraries like `llvmlite` `dlopen` the Python binary, it creates duplicate mappings of the binary in the process memory. This caused PyStack to use the wrong address for `_PyRuntime`, leading to "Invalid address in remote process" errors. It happens to work if we stop searching as soon as we find the symbol, rather than continuing to iterate over other loaded modules searching for the symbol. This counts on the earliest mapping for the interpreter binary being the initial mapping made by the loader. Signed-off-by: Pablo Galindo Salgado Signed-off-by: Matt Wozniski --- news/258.bugfix.rst | 1 + src/pystack/_pystack/unwinder.cpp | 4 +-- tests/integration/ctypes_program.py | 24 ++++++++++++++++++ tests/integration/test_shenanigans.py | 35 +++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 news/258.bugfix.rst create mode 100644 tests/integration/ctypes_program.py create mode 100644 tests/integration/test_shenanigans.py diff --git a/news/258.bugfix.rst b/news/258.bugfix.rst new file mode 100644 index 0000000..241682a --- /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/unwinder.cpp b/src/pystack/_pystack/unwinder.cpp index 2804c12..3ef8b93 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/integration/ctypes_program.py b/tests/integration/ctypes_program.py new file mode 100644 index 0000000..5d174e6 --- /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 0000000..f01b0a0 --- /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