diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0023175d..349e528a 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.3.0 +current_version = 1.7.2 commit = True message = Prepare for {new_version} release diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..b22ad793 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,9 @@ +{ + "name": "PyStack Dockerfile", + "build": { + "context": "..", + "dockerfile": "../Dockerfile" + }, + "runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"], + "onCreateCommand": "pip install -e . && prek install" +} diff --git a/.github/ISSUE_TEMPLATE/---bug-report.yaml b/.github/ISSUE_TEMPLATE/---bug-report.yaml index 6d7a1220..b1901fe1 100644 --- a/.github/ISSUE_TEMPLATE/---bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/---bug-report.yaml @@ -53,6 +53,7 @@ body: - "3.10" - "3.11" - "3.12" + - "3.13" validations: required: true - type: dropdown diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 12301490..9830d5dc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,12 @@ updates: directory: "/" schedule: interval: "daily" + cooldown: + default-days: 7 + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "daily" + cooldown: + default-days: 7 diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 351f35da..cf40ab61 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -1,5 +1,7 @@ name: Wheels +permissions: {} + on: push: pull_request: @@ -19,90 +21,133 @@ jobs: name: Build source distribution runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Build sdist run: pipx run build --sdist - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: + name: sdist path: dist/*.tar.gz - choose_architectures: - name: Decide which architectures to build wheels for + choose_wheel_types: + name: Decide which wheel types to build runs-on: ubuntu-latest steps: - - id: x86_64 - run: echo "cibw_arch=x86_64" >> $GITHUB_OUTPUT - - id: aarch64 - if: github.event_name == 'release' && github.event.action == 'published' - run: echo "cibw_arch=aarch64" >> $GITHUB_OUTPUT + - id: manylinux_x86_64 + run: echo "wheel_types=manylinux_x86_64" >> $GITHUB_OUTPUT + - id: musllinux_x86_64 + run: echo "wheel_types=musllinux_x86_64" >> $GITHUB_OUTPUT + - id: manylinux_aarch64 + run: echo "wheel_types=manylinux_aarch64" >> $GITHUB_OUTPUT outputs: - cibw_arches: ${{ toJSON(steps.*.outputs.cibw_arch) }} + wheel_types: ${{ toJSON(steps.*.outputs.wheel_types) }} build_wheels: - needs: [build_sdist, choose_architectures] - name: Wheel for Linux-${{ matrix.cibw_python }}-${{ matrix.cibw_arch }} + needs: [build_sdist, choose_wheel_types] + name: ${{ matrix.wheel_type }} wheels runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest] - cibw_python: ["cp37-*", "cp38-*", "cp39-*", "cp310-*", "cp311-*", "cp312-*"] - cibw_arch: ${{ fromJSON(needs.choose_architectures.outputs.cibw_arches) }} + wheel_type: ${{ fromJSON(needs.choose_wheel_types.outputs.wheel_types) }} + include: + - os: ubuntu-latest + - wheel_type: manylinux_aarch64 + os: ubuntu-24.04-arm steps: - name: Disable ptrace security restrictions run: | echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope - - uses: docker/setup-qemu-action@v3 - if: runner.os == 'Linux' - name: Set up QEMU - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: artifact + name: sdist - name: Extract sdist run: | tar zxvf *.tar.gz --strip-components=1 - name: Build wheels - uses: pypa/cibuildwheel@v2.16.2 + uses: pypa/cibuildwheel@e090b81e30c4d855ea63bf4b6e59204c09a101ae # v4.2.1 env: - CIBW_BUILD: ${{ matrix.cibw_python }} - CIBW_ARCHS_LINUX: ${{ matrix.cibw_arch }} - CIBW_PRERELEASE_PYTHONS: True - - uses: actions/upload-artifact@v3 + CIBW_BUILD: "cp3{9..15}{t,}-${{ matrix.wheel_type }}" + CIBW_ARCHS_LINUX: auto + CIBW_ENABLE: cpython-prerelease + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: + name: ${{ matrix.wheel_type }}-wheels path: ./wheelhouse/*.whl - test_attaching_to_old_interpreters: + build_cp313t_wheels: + needs: [build_sdist, choose_wheel_types] + name: ${{ matrix.wheel_type }} wheels + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + wheel_type: ${{ fromJSON(needs.choose_wheel_types.outputs.wheel_types) }} + include: + - os: ubuntu-latest + - wheel_type: manylinux_aarch64 + os: ubuntu-24.04-arm + + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sdist + - name: Extract sdist + run: | + tar zxvf *.tar.gz --strip-components=1 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - name: Install cibuildwheel v3 + run: python -m pip install 'cibuildwheel<4' + - name: Build wheels + run: python -m cibuildwheel + env: + CIBW_BUILD: "cp313t-${{ matrix.wheel_type }}" + CIBW_ARCHS_LINUX: auto + CIBW_ENABLE: cpython-freethreading + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.wheel_type }}-cp313t-wheels + path: ./wheelhouse/*.whl + + test_attaching_to_eol_interpreters: needs: [build_wheels] - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: - python_version: ["2.7", "3.6"] + python_version: ["2.7", "3.7"] steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: artifact + name: "manylinux_x86_64-wheels" path: dist - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.11" + python-version: '>=3.13' - name: Set up dependencies run: | sudo add-apt-repository ppa:deadsnakes/ppa sudo apt-get update sudo apt-get install -qy \ gdb \ - python2.7-dev python2.7-dbg \ - python3.6-dev python3.6-dbg python3.6-distutils + python${{ matrix.python_version }}-dev \ + python${{ matrix.python_version }}-dbg + sudo apt-get install -qy \ + python${{ matrix.python_version }}-distutils || true - name: Install Python dependencies run: | python3 -m pip install --upgrade pip - python3 -m pip install -r requirements-test.txt + python3 -m pip install --group test python3 -m pip install --no-index --find-links=dist/ --only-binary=pystack pystack - name: Disable ptrace security restrictions run: | @@ -112,23 +157,104 @@ jobs: PYTHON_TEST_VERSION: ${{ matrix.python_version }} run: python3 -m pytest tests -n auto -vvv - test_wheels: + test_attaching_to_uv_interpreters: needs: [build_wheels] - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python_version: ["3.13"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "manylinux_x86_64-wheels" + path: dist + - name: Install uv + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + version: latest + python-version: ${{ matrix.python_version }} + activate-environment: true + enable-cache: false + - name: Set up dependencies + run: | + sudo apt-get update + sudo apt-get install -qy gdb + - name: Install Python dependencies + run: | + uv pip install --group test + 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, build_cp313t_wheels] + runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: - python_version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python_version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.13t", "3.14", "3.14t", "3.15", "3.15t"] steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - name: artifact + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "${{matrix.python_version}}-dev" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "manylinux_x86_64-wheels" path: dist + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "manylinux_x86_64-cp313t-wheels" + path: dist + - name: Set up dependencies + run: | + sudo add-apt-repository ppa:deadsnakes/ppa + sudo apt-get update + sudo apt-get install -qy gdb + - name: Install Python dependencies + run: | + python${{matrix.python_version}} -m pip install --upgrade pip + python${{matrix.python_version}} -m pip install --group test + python${{matrix.python_version}} -m 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: "auto" + run: python${{matrix.python_version}} -m pytest tests -n auto -vvv + + test_aarch64_wheels: + needs: [build_wheels] + runs-on: ubuntu-24.04-arm + strategy: + fail-fast: false + matrix: + python_version: ["3.9", "3.13", "3.14", "3.15"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "${{matrix.python_version}}-dev" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "manylinux_aarch64-wheels" + path: dist - name: Set up dependencies run: | sudo add-apt-repository ppa:deadsnakes/ppa @@ -136,12 +262,11 @@ jobs: sudo apt-get install -qy \ gdb \ python${{matrix.python_version}}-dev \ - python${{matrix.python_version}}-dbg \ - python${{matrix.python_version}}-distutils + python${{matrix.python_version}}-dbg - name: Install Python dependencies run: | python${{matrix.python_version}} -m pip install --upgrade pip - python${{matrix.python_version}} -m pip install -r requirements-test.txt + python${{matrix.python_version}} -m pip install --group test python${{matrix.python_version}} -m pip install --no-index --find-links=dist/ --only-binary=pystack pystack - name: Disable ptrace security restrictions run: | @@ -149,7 +274,52 @@ jobs: - name: Run pytest env: PYTHON_TEST_VERSION: "auto" - run: python${{matrix.python_version}} -m pytest tests -k 'not 2.7' -n auto -vvv + run: python${{matrix.python_version}} -m pytest tests -n auto -vvv + + test_free_threading: + needs: [build_wheels] + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + python_version: ["3.13", "3.14", "3.15"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "${{matrix.python_version}}-dev" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "manylinux_x86_64-wheels" + path: dist + - name: Set up dependencies + run: | + sudo add-apt-repository ppa:deadsnakes/ppa + sudo apt-get update + sudo apt-get install -qy \ + gdb \ + python${{matrix.python_version}}-dev \ + python${{matrix.python_version}}-nogil \ + python${{matrix.python_version}}-venv + - name: Install Python dependencies + run: | + python${{matrix.python_version}} -m pip install --upgrade pip + python${{matrix.python_version}} -m pip install --group test + python${{matrix.python_version}} -m pip install --no-index --find-links=dist/ --only-binary=pystack pystack + - name: Install setuptools for the free-threading version + run: | + python${{matrix.python_version}}t -m venv --system-site-packages /tmp/pip${{matrix.python_version}} + /tmp/pip${{matrix.python_version}}/bin/pip install --user setuptools + - 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}}t" + run: python${{matrix.python_version}} -m pytest tests -n auto -vvv test_in_alpine: needs: [build_wheels] @@ -158,28 +328,30 @@ jobs: fail-fast: false container: - image: alpine + image: alpine # zizmor: ignore[unpinned-images] options: --cap-add=SYS_PTRACE steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - name: artifact - path: dist + persist-credentials: false - name: Set up dependencies run: | apk add --update alpine-sdk bash alpine-sdk python3 python3-dev gdb musl-dbg python3-dbg + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "musllinux_x86_64-wheels" + path: dist - name: Install Python dependencies run: | python3 -m venv venv venv/bin/python3 -m pip install --upgrade pip - venv/bin/python3 -m pip install -r requirements-test.txt + venv/bin/python3 -m pip install --group test venv/bin/python3 -m pip install --no-index --find-links=dist/ --only-binary=pystack pystack - name: Run pytest env: PYTHON_TEST_VERSION: "auto" - run: venv/bin/python3 -m pytest tests -k 'not 2.7' -n auto -vvv + run: venv/bin/python3 -m pytest tests -n auto -vvv test_wheels_in_fedora: needs: [build_wheels] @@ -188,15 +360,13 @@ jobs: fail-fast: false container: - image: fedora + image: fedora # zizmor: ignore[unpinned-images] options: --cap-add=SYS_PTRACE steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - name: artifact - path: dist + persist-credentials: false - name: Set up dependencies run: | dnf install -y \ @@ -207,15 +377,19 @@ jobs: python3-devel dnf debuginfo-install -y \ python3 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "manylinux_x86_64-wheels" + path: dist - name: Install Python dependencies run: | python3 -m pip install --upgrade pip - python3 -m pip install -r requirements-test.txt + python3 -m pip install --group test python3 -m pip install --no-index --find-links=dist/ --only-binary=pystack pystack - name: Run pytest env: PYTHON_TEST_VERSION: "auto" - run: python3 -m pytest tests -k 'not 2.7' -n auto -vvv + run: python3 -m pytest tests -n auto -vvv test_wheels_in_arch: needs: [build_wheels] @@ -224,14 +398,12 @@ jobs: fail-fast: false container: - image: archlinux + image: archlinux # zizmor: ignore[unpinned-images] options: --cap-add=SYS_PTRACE --security-opt seccomp=unconfined steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - name: artifact - path: dist + persist-credentials: false - name: Set up dependencies run: | pacman -Syu --noconfirm \ @@ -241,17 +413,24 @@ jobs: python \ python-pip \ python-setuptools \ - python-wheel + python-wheel \ + elfutils \ + debuginfod + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "manylinux_x86_64-wheels" + path: dist - name: Install Python dependencies run: | python -m venv venv venv/bin/python -m pip install --upgrade pip - venv/bin/python -m pip install -r requirements-test.txt + venv/bin/python -m pip install --group test venv/bin/python -m pip install --no-index --find-links=dist/ --only-binary=pystack pystack - name: Run pytest env: PYTHON_TEST_VERSION: "auto" - run: venv/bin/python -m pytest tests -k 'not 2.7' -n auto -vvv + DEBUGINFOD_URLS: "https://debuginfod.archlinux.org" + run: venv/bin/python -m pytest tests -n auto -vvv test_wheels_in_debian: needs: [build_wheels] @@ -260,15 +439,13 @@ jobs: fail-fast: false container: - image: debian + image: debian # zizmor: ignore[unpinned-images] options: --cap-add=SYS_PTRACE steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - name: artifact - path: dist + persist-credentials: false - name: Set up dependencies run: | apt-get update @@ -278,29 +455,42 @@ jobs: python3-dev \ python3-pip \ python3-venv \ - python3-dbg \ - python3-distutils + python3-dbg + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "manylinux_x86_64-wheels" + path: dist - name: Install Python dependencies run: | python3 -m venv venv venv/bin/python3 -m pip install --upgrade pip - venv/bin/python3 -m pip install -r requirements-test.txt + venv/bin/python3 -m pip install --group test venv/bin/python3 -m pip install --no-index --find-links=dist/ --only-binary=pystack pystack - name: Run pytest env: PYTHON_TEST_VERSION: "auto" - run: venv/bin/python3 -m pytest tests -k 'not 2.7' -n auto -vvv + run: venv/bin/python3 -m pytest tests -n auto -vvv upload_pypi: needs: [test_wheels] runs-on: ubuntu-latest if: github.event_name == 'release' && github.event.action == 'published' + permissions: + id-token: write # Required to retrieve a Trusted Publishing token steps: - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: artifact + # with no name set, it downloads all of the artifacts path: dist - - uses: pypa/gh-action-pypi-publish@release/v1 + - run: | + mv dist/sdist/*.tar.gz dist/ + mv dist/*-wheels/*.whl dist/ + rmdir dist/{sdist,*-wheels} + ls -R dist + - name: Avoid publishing Python 3.16 wheels as its ABI is not yet frozen + run: | + rm -f dist/*cp316* + ls -R dist + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: skip_existing: true - password: ${{ secrets.PYPI_PASSWORD }} diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 7baa8d64..2432ba2c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -27,9 +27,11 @@ jobs: strategy: fail-fast: false steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.10" - name: Set up dependencies @@ -38,32 +40,43 @@ jobs: sudo apt-get install -qy \ gdb \ lcov \ + cmake \ + ninja-build \ libdw-dev \ libelf-dev \ python3.10-dev \ python3.10-dbg - name: Install Python dependencies run: | - python3 -m pip install --upgrade pip cython pkgconfig - make test-install + python3 -m pip install --upgrade pip scikit-build-core nanobind + python3 -m pip install -e . --group test - name: Disable ptrace security restrictions run: | echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope - - name: Compute Python + Cython coverage + - name: Compute Python coverage run: | - make pycoverage + python3 -m pytest -vvv --log-cli-level=info -s --color=yes \ + --cov=pystack --cov=tests --cov-config=pyproject.toml --cov-report=term \ + --cov-append tests --cov-fail-under=85 + python3 -m coverage lcov -i -o pycoverage.lcov + genhtml *coverage.lcov --branch-coverage --output-directory pystack-coverage - name: Compute C++ coverage run: | - make ccoverage - - name: Upload {P,C}ython report to Codecov - uses: codecov/codecov-action@v3 + rm -rf build + CFLAGS="-O0 -pg --coverage" CXXFLAGS="-O0 -pg --coverage" SKBUILD_BUILD_DIR=build pip install -e . --no-build-isolation + python3 -m pytest tests -v + find build -name "*.gcda" -o -name "*.gcno" | head -5 + lcov --capture --directory build --output-file cppcoverage.lcov + lcov --extract cppcoverage.lcov '*/src/pystack/_pystack/*' --output-file cppcoverage.lcov + - name: Upload Python report to Codecov + uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 with: - token: ${{ secrets.CODECOV_TOKEN }} + token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] files: pycoverage.lcov - flags: python_and_cython + flags: python - name: Upload C++ report to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 with: - token: ${{ secrets.CODECOV_TOKEN }} + token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] files: cppcoverage.lcov flags: cpp diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..f80c4db8 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,42 @@ +name: Publish docs + +permissions: {} + +on: + push: + branches: + - main + +jobs: + publish_docs: + name: Publish docs + runs-on: ubuntu-latest + #if: github.event_name == 'release' && github.event.action == 'published' + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.10" + - name: Set up dependencies + run: | + sudo apt-get update + sudo apt-get install -qy libdw-dev libelf-dev pkg-config + - name: Install Python dependencies + run: | + python3 -m pip install --group extra + - name: Install Package + run: | + python3 -m pip install -e . + - name: Build docs + run: | + make docs + - name: Publish docs to GitHub Pages + uses: JamesIves/github-pages-deploy-action@fa24774553152dd7873cd16ebd8d959b010c5445 # v4.9.0 + with: + folder: docs/_build/html + single-commit: true diff --git a/.github/workflows/lint_and_docs.yml b/.github/workflows/lint_and_docs.yml index f51c1258..c8d17e70 100644 --- a/.github/workflows/lint_and_docs.yml +++ b/.github/workflows/lint_and_docs.yml @@ -1,13 +1,17 @@ name: Lint and docs +permissions: {} + on: [push, pull_request] jobs: lint_and_docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.10" - name: Set up dependencies @@ -16,46 +20,27 @@ jobs: sudo apt-get install -qy libdw-dev libelf-dev - name: Install Python dependencies run: | - python3 -m pip install -r requirements-extra.txt + python3 -m pip install --group extra - name: Install Package run: | python3 -m pip install -e . - name: Lint sources run: | - make lint - python3 -m pre_commit run --all-files --hook-stage pre-push + make lint PYTHON=python3 - name: Build docs run: | towncrier build --version 99.99 --name pystack --keep make docs - publish_docs: - name: Publish docs + zizmor: + name: Run zizmor ๐ŸŒˆ runs-on: ubuntu-latest - if: github.event_name == 'release' && github.event.action == 'published' permissions: - contents: write + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - name: Set up dependencies - run: | - sudo apt-get update - sudo apt-get install -qy libdw-dev libelf-dev pkg-config - - name: Install Python dependencies - run: | - python3 -m pip install -r requirements-extra.txt - - name: Install Package - run: | - python3 -m pip install -e . - - name: Build docs - run: | - make docs - - name: Publish docs to GitHub Pages - uses: JamesIves/github-pages-deploy-action@v4 + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - folder: docs/_build/html - single-commit: true + persist-credentials: false + - name: Run zizmor ๐ŸŒˆ + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 diff --git a/.github/workflows/news-check.yml b/.github/workflows/news-check.yml index bcc4b976..2e442386 100644 --- a/.github/workflows/news-check.yml +++ b/.github/workflows/news-check.yml @@ -1,4 +1,7 @@ name: News entry check + +permissions: {} + on: pull_request: paths: @@ -16,9 +19,11 @@ jobs: name: Check for news entry steps: - name: "Check for news entry" - uses: brettcannon/check-for-changed-files@v1 + uses: brettcannon/check-for-changed-files@d85c64d17b3c1d0ac57c9cc46ea39399b0d6fa71 # v1.2.2 with: - file-pattern: "news/*.rst" + file-pattern: | + news/*.rst + NEWS.rst skip-label: "skip news" failure-message: "Missing a news file in ${file-pattern}; please add one or apply the ${skip-label} diff --git a/.github/workflows/sanity-check.yml b/.github/workflows/sanity-check.yml index c8cc276c..31965c6c 100644 --- a/.github/workflows/sanity-check.yml +++ b/.github/workflows/sanity-check.yml @@ -1,4 +1,7 @@ name: Sanity check + +permissions: {} + on: pull_request: types: @@ -15,10 +18,10 @@ jobs: steps: - name: Get PR Commits id: "get-pr-commits" - uses: tim-actions/get-pr-commits@master + uses: tim-actions/get-pr-commits@198af03565609bb4ed924d1260247b4881f09e7d # master with: token: ${{ secrets.GITHUB_TOKEN }} - name: DCO Check - uses: tim-actions/dco@master + uses: tim-actions/dco@f2279e6e62d5a7d9115b0cb8e837b777b1b02e21 # master with: commits: ${{ steps.get-pr-commits.outputs.commits }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d4d029e4..0114f4fb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: check-added-large-files - id: check-json @@ -10,43 +10,44 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace exclude: "^[.]bumpversion[.]cfg" + exclude_types: [diff] - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.10.0 + rev: 3a6eb0fadf60b3cccfd80bad9dbb6fae7e47b316 # frozen: v1.10.0 hooks: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.262" + rev: "5e2fb545eba1ea9dc051f6f962d52fe8f76a9794" # frozen: v0.15.13 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] stages: [pre-commit] - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 87928e6d6761a4a6d22250e1fee5601b3998086e # frozen: 26.5.1 hooks: - id: black - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: dac090ce4d9ee313d086e2e89ab1acb8c2664fa1 # frozen: 9.0.0a3 hooks: - id: isort exclude_types: [python] - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.6.7 + rev: c883505f64b59c3c5c9375191e4ad9f98e727ccd # frozen: v1.0.2 hooks: - id: sphinx-lint - repo: https://github.com/executablebooks/mdformat/ - rev: 0.7.16 + rev: 82912cdaea4fb830f751504486a7879c70526547 # frozen: 1.0.0 hooks: - id: mdformat - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v16.0.2 + rev: dd18dad857d6133e90bbe478f4f2f22ec0030269 # frozen: v22.1.5 hooks: - id: clang-format args: [--Werror, -i] diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 00000000..cae0e616 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,87 @@ +cmake_minimum_required(VERSION 3.17...3.27) + +project(pystack LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE) + +# Find Python +find_package(Python COMPONENTS Interpreter Development.Module Development.SABIModule REQUIRED) + +# Set compiler flags +set(lto_configs RELEASE RELWITHDEBINFO MINSIZEREL) +string(TOUPPER "${CMAKE_BUILD_TYPE}" build_type) + +# Probe for LTO support if a configuration that would use it can be built. +if(CMAKE_CONFIGURATION_TYPES OR build_type IN_LIST lto_configs) + include(CheckIPOSupported) + + check_ipo_supported( + RESULT ipo_supported + OUTPUT ipo_error + LANGUAGES CXX + ) + + if(ipo_supported) + foreach(config IN LISTS lto_configs) + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_${config} ON) + endforeach() + else() + message(WARNING "LTO disabled: ${ipo_error}") + endif() +endif() + +add_compile_options(-Wall) +add_compile_definitions(_FILE_OFFSET_BITS=64) + +add_compile_definitions( + "$<$:_GLIBCXX_DEBUG>" + "$<$:_LIBCPP_DEBUG>" +) + +set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -Og") + +# Find nanobind +execute_process( + COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir + OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_VARIABLE nanobind_ROOT) +find_package(nanobind CONFIG REQUIRED) + +# Find libelf and libdw via pkg-config +function(make_pkgconfig_target target modulespec) + find_package(PkgConfig REQUIRED) + pkg_check_modules(${target} REQUIRED ${modulespec}) + + set(static "") + if(${target}_LINK_LIBRARIES) + list(GET ${target}_LINK_LIBRARIES 0 lib) + get_filename_component(ext "${lib}" EXT) + if(ext STREQUAL "${CMAKE_STATIC_LIBRARY_SUFFIX}") + set(static "_STATIC") + endif() + endif() + + # pkg-config doesn't report that libdw.a needs libdl.so + set(libraries ${${target}${static}_LIBRARIES}) + if(static) + list(APPEND libraries ${CMAKE_DL_LIBS}) + endif() + + add_library(${target} INTERFACE IMPORTED) + set_target_properties(${target} PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${${target}${static}_INCLUDE_DIRS}" + INTERFACE_LINK_DIRECTORIES "${${target}${static}_LIBRARY_DIRS}" + INTERFACE_LINK_LIBRARIES "${libraries}" + INTERFACE_COMPILE_OPTIONS "${${target}${static}_CFLAGS_OTHER}" + INTERFACE_LINK_OPTIONS "${${target}${static}_LDFLAGS_OTHER}" + ) +endfunction() + +make_pkgconfig_target(LIBELF libelf) +make_pkgconfig_target(LIBDW libdw) + +# Add the extension module subdirectory +add_subdirectory(src/pystack/_pystack) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9216621b..2586c953 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,11 +5,45 @@ issue, or any other method of communication with the maintainers of this reposit You can also search this project for issues with the following labels: -| Label | Search Term | Description | +| Label | Search Term | Description | | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [good-first-issue](https://github.com/bloomberg/pystack/search?q=is%3Aissue+is%3Aopen+label%3Agood-first-issue&type=Issues&utf8=%E2%9C%93) | `is:issue is:open label:good-first-issue` | Recommended for first-time contributors! These are well-defined, and allow a user to get familiar with the project's workflow before tackling more complex issues. | -| [help wanted](https://github.com/bloomberg/pystack/search?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22+&type=Issues&utf8=%E2%9C%93) | `is:issue is:open label:"help wanted"` | General issues where contributors help is wanted. | -| [question](https://github.com/bloomberg/pystack/search?q=is%3Aissue+is%3Aopen+label%3Aquestion&type=Issues&utf8=%E2%9C%93) | `is:issue is:open label:question` | Open discussions to resolve everything from implementation details to desired functionality. | +| [help wanted](https://github.com/bloomberg/pystack/search?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22+&type=Issues&utf8=%E2%9C%93) | `is:issue is:open label:"help wanted"` | General issues where contributors help is wanted. | +| [question](https://github.com/bloomberg/pystack/search?q=is%3Aissue+is%3Aopen+label%3Aquestion&type=Issues&utf8=%E2%9C%93) | `is:issue is:open label:question` | Open discussions to resolve everything from implementation details to desired functionality. | + +## Setting up a development environment + +### GitHub Codespaces + +The repository ships with a dev container configuration that works with +[GitHub Codespaces](https://github.com/features/codespaces). You can open +a ready-to-use environment directly: + +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/bloomberg/pystack) + +Once the codespace is ready, all dependencies will be installed and hooks +will be configured automatically. + +Note that the dev container is built from the project's `Dockerfile`, which +uses the distribution-provided `libdw` and `libelf` development packages. + +pystack requires elevated ptrace permissions to inspect processes โ€” the dev +container already handles this via `--cap-add=SYS_PTRACE` and +`seccomp=unconfined`, so things should work out of the box. + +### Local setup + +You'll need Linux with `libdw`, `libelf`, CMake, and a C++ compiler (Clang or +GCC). Then: + +```shell +git clone https://github.com/bloomberg/pystack.git pystack +cd pystack +python3 -m venv ../pystack-env/ +source ../pystack-env/bin/activate +pip install -e . -r requirements-test.txt -r requirements-extra.txt +prek install +``` ## Contribution Licensing @@ -57,15 +91,30 @@ Changes should always include tests. If this is a bug fix it is a good idea to a first commit of the pull request and the changes to fix the issue in subsequent commits to make it easier to validate it. +### Running tests before submitting pull request + +To run local tests, first install the test requirements: + +```shell +python3 -m pip install --upgrade pip +python3 -m pip install -e . --group test +``` + +Then, run the check: + +```shell +make check +``` + ## Pull requests ### Linting your code -Before commiting anything, install the pre-commit hooks: +Before commiting anything, install the prek (pre-commit) hooks: ```shell -python3 -m pip install -r requirements-extra.txt -pre-commit install +python3 -m pip install --group extra +prek install ``` This will ensure that your contribution passes our linting checks. diff --git a/Dockerfile b/Dockerfile index 56145f43..d6ada201 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,95 +1,54 @@ -# Stage 1: Elfutils build stage -FROM ubuntu:22.04 AS elfutils_builder -ARG DEBIAN_FRONTEND=noninteractive -ENV VERS=0.189 - -# Install elfutils build dependencies -RUN apt-get update \ - && apt-get install -y --force-yes --no-install-recommends software-properties-common gpg-agent \ - build-essential \ - libzstd-dev \ - ca-certificates \ - curl \ - lsb-release \ - bzip2 \ - zlib1g-dev \ - zlib1g-dev:native \ - libbz2-dev \ - liblzma-dev \ - gettext \ - po-debconf \ - gawk \ - libc6-dbg \ - flex \ - bison \ - pkg-config \ - libarchive-dev \ - libmicrohttpd-dev \ - libcurl4-gnutls-dev \ - libsqlite3-dev \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* \ - && mkdir /elfutils \ - && cd /elfutils \ - && curl https://sourceware.org/elfutils/ftp/$VERS/elfutils-$VERS.tar.bz2 > ./elfutils.tar.bz2 \ - && tar -xf elfutils.tar.bz2 \ - && cd elfutils-$VERS \ - && CFLAGS='-w -DFNM_EXTMATCH=0' ./configure --prefix=/usr --disable-nls --disable-libdebuginfod --disable-debuginfod --with-zstd \ - && make install - -# Stage 2: Final stage -FROM ubuntu:22.04 +# Build stage +FROM ubuntu:26.04 ARG DEBIAN_FRONTEND=noninteractive +LABEL org.opencontainers.image.source="https://github.com/bloomberg/pystack" # Install runtime dependencies RUN apt-get update \ - && apt-get install -y --force-yes --no-install-recommends software-properties-common gpg-agent \ - && add-apt-repository ppa:deadsnakes/ppa \ && apt-get install -y --force-yes --no-install-recommends \ build-essential \ pkg-config \ - python3.7-dev \ - python3.7-dbg \ - python3.7-distutils \ - python3.8-dev \ - python3.8-dbg \ - python3.8-distutils \ - python3.9-dev \ - python3.9-dbg \ - python3.9-distutils \ - python3.10-dev \ - python3.10-dbg \ - python3.10-distutils \ - python3.11-dev \ - python3.11-dbg \ - python3.11-distutils \ - python3.10-full \ make \ cmake \ gdb \ + git \ valgrind \ lcov \ file \ + less \ + libcrypt-dev \ + libzstd-dev \ + liblzma-dev \ + libbz2-dev \ + zlib1g-dev \ + libdw-dev \ + libelf-dev \ && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -# Copy the installed files from the elfutils_builder stage -COPY --from=elfutils_builder /usr /usr - -# Copy the required files -COPY ["requirements-test.txt", "requirements-extra.txt", "requirements-docs.txt", "/tmp/"] + && rm -rf /var/lib/apt/lists/* /etc/debuginfod/*.urls -# Install Python packages -RUN python3.10 -m venv /venv \ - && /venv/bin/python -m pip install -U pip wheel setuptools cython pkgconfig \ - && /venv/bin/python -m pip install -U -r /tmp/requirements-test.txt -r /tmp/requirements-extra.txt +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv # Set environment variables -ENV PYTHON=python3.10 \ - VIRTUAL_ENV="/venv" \ - PATH="/venv/bin:$PATH" \ +ENV VIRTUAL_ENV="/venv" \ + PATH="/venv/bin:/root/.local/bin:/usr/local/sbin:/usr/local/bin:/sbin:/bin" \ PYTHONDONTWRITEBYTECODE=1 \ TZ=UTC +# Install Python interpreters via uv, and install setuptools for each +RUN uv_versions="3.8 3.9 3.10 3.11 3.12 3.13 3.14 3.15" \ + && uv python install --no-config $uv_versions \ + && for ver in $uv_versions; do \ + PATH=/root/.local/bin /usr/local/bin/uv pip install --system --break-system-packages --python=python$ver setuptools; \ + done + +# Copy the required files +COPY pyproject.toml /tmp/ + +# Install Python packages +RUN uv venv $VIRTUAL_ENV \ + && uv pip install pip wheel setuptools cython pkgconfig \ + && uv pip install --group "/tmp/pyproject.toml:test" --group "/tmp/pyproject.toml:extra" + # Set the working directory WORKDIR /src diff --git a/MANIFEST.in b/MANIFEST.in index d8053ae3..c0b658b5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,7 +3,6 @@ exclude CONTRIBUTING.md exclude Dockerfile exclude src/pystack/*.cpp exclude src/pystack/*.h -exclude requirements-*.txt exclude tox.ini exclude valgrind.supp @@ -14,7 +13,6 @@ include README.md include src/pystack/py.typed include .bumpversion.cfg include .pre-commit-config.yaml -include build_scripts/elfutils_contiguous_segments.diff recursive-include src/pystack/_pystack * recursive-include src/pystack *.py diff --git a/Makefile b/Makefile index de9a9424..93116238 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PYTHON ?= python +PYTHON ?= python3 DOCKER_IMAGE ?= pystack DOCKER_SRC_DIR ?= /src @@ -13,11 +13,11 @@ ENV := .PHONY: build build: ## (default) Build package extensions in-place - $(PYTHON) setup.py build_ext --inplace + $(ENV) $(PIP_INSTALL) -e . .PHONY: dist dist: ## Generate Python distribution files - $(PYTHON) -m pep517.build . + $(PYTHON) -m build .PHONY: install-sdist install-sdist: dist ## Install from source distribution @@ -25,7 +25,7 @@ install-sdist: dist ## Install from source distribution .PHONY: test-install test-install: ## Install with test dependencies - $(ENV) CYTHON_TEST_MACROS=1 $(PIP_INSTALL) -e . -r requirements-test.txt + $(ENV) $(PIP_INSTALL) -e . -r requirements-test.txt .PHONY: docker-build docker-build: ## Build the Docker image @@ -59,7 +59,7 @@ check: ## Run the test suite pycoverage: ## Run the test suite, with Python code coverage $(PYTHON) -m pytest -vvv --log-cli-level=info -s --color=yes \ --cov=pystack --cov=tests --cov-config=pyproject.toml --cov-report=term \ - --cov-append $(PYTEST_ARGS) tests --cov-fail-under=92 + --cov-append $(PYTEST_ARGS) tests --cov-fail-under=85 $(PYTHON) -m coverage lcov -i -o pycoverage.lcov genhtml *coverage.lcov --branch-coverage --output-directory pystack-coverage @@ -71,20 +71,19 @@ valgrind: ## Run valgrind, with the correct configuration .PHONY: ccoverage ccoverage: ## Run the test suite, with C++ code coverage $(MAKE) clean - CFLAGS="$(CFLAGS) -O0 -pg --coverage" $(MAKE) build + CFLAGS="-O0 -pg --coverage" CXXFLAGS="-O0 -pg --coverage" $(PIP_INSTALL) -e . $(MAKE) check - gcov -i build/*/src/pystack/_pystack -i -d - lcov --capture --directory . --output-file cppcoverage.lcov + lcov --capture --directory . --output-file cppcoverage.lcov lcov --extract cppcoverage.lcov '*/src/pystack/_pystack/*' --output-file cppcoverage.lcov genhtml *coverage.lcov --branch-coverage --output-directory pystack-coverage .PHONY: format format: ## Autoformat all files - $(PYTHON) -m pre_commit run --all-files + $(PYTHON) -m prek run --all-files .PHONY: lint lint: ## Lint all files - $(PYTHON) -m pre_commit run --all-files + $(PYTHON) -m prek run --all-files $(PYTHON) -m mypy src/pystack --strict --ignore-missing-imports $(PYTHON) -m mypy tests --ignore-missing-imports @@ -93,6 +92,11 @@ docs: ## Generate documentation $(MAKE) -C docs clean $(MAKE) -C docs html +.PHONY: docs-live +docs-live: ## Serve documentation on localhost:8000, with live-reload + $(MAKE) -C docs clean + $(MAKE) -C docs livehtml + .PHONY: gh-pages gh-pages: ## Publish documentation on GitHub Pages $(eval GIT_REMOTE := $(shell git remote get-url $(UPSTREAM_GIT_REMOTE))) @@ -111,6 +115,7 @@ clean: ## Clean any built/generated artifacts find . | grep -E '(\.o|\.gcda|\.gcno|\.gcov\.json\.gz)' | xargs rm -rf find . | grep -E '(__pycache__|\.pyc|\.pyo)' | xargs rm -rf rm -rf build + rm -rf _skbuild rm -f src/pystack/_pystack.*.so rm -f {cpp,py}coverage.lcov rm -rf pystack-coverage diff --git a/NEWS.rst b/NEWS.rst index 6536f179..db80cd62 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -8,6 +8,151 @@ Changelog .. towncrier release notes start +pystack 1.7.2 (2026-09-22) +-------------------------- + +Deprecations and Removals +~~~~~~~~~~~~~~~~~~~~~~~~~ + +- PyStack no longer falls back to running the interpreter's executable with ``--version`` to determine its version, since that could be unsafe for embedded interpreters. Since this was already a last resort, this should have minimal impact on users. (#359) + + +Bug Fixes +~~~~~~~~~ + +- AArch64 wheels can now unwind through Linux signal frames, preserving the interrupted frames when inspecting a process in a signal handler. (#348) +- Avoid repeated whole-map reads when a readable memory map contains an inaccessible guard page. Reads of accessible addresses in the same map now bypass the cache after the first failed cache fill. (#352) +- Prevent reading past the end of a truncated or malformed core file's ``NT_FILE`` note. (#358) +- Explicitly reject core files whose endianness or word size does not match PyStack's. These would never have worked reliably, but until now the reason wasn't clearly surfaced to the user. (#360) +- Ensure PyStack considers every ``PT_NOTE`` segment in a core file that has multiple. (#366) + + +pystack 1.7.1 (2026-08-07) +-------------------------- + +Features +~~~~~~~~ + +- Wheels for Python 3.15 are now published. (#342) + +Bug Fixes +~~~~~~~~~ + +- Our locating of the ``_PyRuntime`` symbol has been made more robust. (#336) +- An incompatibility with Python 3.15 introduced in 3.15.0b4 has been fixed. (#337) + +Miscellaneous +~~~~~~~~~~~~~ + +- We now publish ``abi3`` wheels for Python 3.12 and later. (#331) +- Release builds of PyStack are now built with LTO (Link Time Optimization) enabled. (#330) +- Editable installs of PyStack are now built with ``-Og`` (optimizations suitable for debugging) enabled. (#330) + +pystack 1.7.0 (2026-07-09) +-------------------------- + +Features +~~~~~~~~ + +- Replace Cython with nanobind for Python bindings, improving performance by eliminating round-trips between Python and C++. (#272) +- Add :ref:`support for Python subinterpreters `. When a process uses multiple interpreters (e.g. via Python 3.14's `concurrent.interpreters` module), stacks for all interpreters are now reported instead of just the main one. (#279) +- Python 3.15 is now supported. The wheels are not yet published because the ABI is not yet frozen. (#289) +- PyStack is now built against elfutils 0.195, up from 0.193. (#315) + + +Bug Fixes +~~~~~~~~~ + +- Fix compatibility with the Python 3.14 JIT compiler. (#277) +- Fix native mode stacks for Python 3.14's LLVM-generated ``*_TAIL_CALL_*`` frames. (#277) +- Fix ``IndexError`` in traceback formatting when the source file has fewer lines than expected. (#191) +- Fix an extra blank line sometimes being printed in the middle of a traceback. (#298) + + +pystack 1.6.0 (2026-01-25) +-------------------------- + +Features +~~~~~~~~ + +- Provide wheels for free-threaded builds of Python 3.13 (#271) +- Add the Python 3.14 trove classifier to our package metadata (#269) +- Improve some error messages and log messages (#260, #258) + + +Bug Fixes +~~~~~~~~~ + +- 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. (#258) + + +pystack 1.5.1 (2025-08-18) +-------------------------- + +Bug Fixes +~~~~~~~~~ + +- Correct a bug in our GitHub Actions workflow that prevented artifacts for 1.5.0 from being uploaded to PyPI. No changes have been made to the PyStack code itself since 1.5.0 (#250) + + +pystack 1.5.0 (2025-08-18) +-------------------------- + +Features +~~~~~~~~ + +- Add a new ``--native-last`` command line flag to only show the C frames after the last Python frame. (#182) +- Add support for Python 3.14 (#229) +- Support kernels that don't have CONFIG_CROSS_MEMORY_ATTACH set. (#240) + + +Deprecations and Removals +~~~~~~~~~~~~~~~~~~~~~~~~~ + +- We no longer provide x86-64 musllinux_1_1 wheels. The manylinux project dropped support for musllinux_1_1 on November 1st, 2024. (#237) +- We no longer provide Python 3.7 wheels. Python 3.7 has been end-of-life since June 2023. (#237) + + +Bug Fixes +~~~~~~~~~ + +- Fix incorrect file offset calculation when analyzing ELF files with + non-standard ELF layouts. Previously, pystack would fail to correctly analyze + Python binaries that had non-standard ELF layouts (for example when compiled + with certain linker options). The fix properly accounts for PT_LOAD segment + mappings when calculating file offsets. (#220) +- Improve handling of core files where we cannot determine the executable. (#221) + + +pystack 1.4.1 (2024-10-04) +-------------------------- + +Features +~~~~~~~~ + +- Improve our ability to debug Python 3.13 processes when the ``_Py_DebugOffsets`` cannot be located by accounting for a structure layout change in CPython 3.13.0rc3 (the reversion of the incremental cyclic garbage collector). (#213) + + +pystack 1.4.0 (2024-09-11) +-------------------------- + +Features +~~~~~~~~ + +- Add support for Python 3.13 (#186) +- Add support for gzip compressed corefiles (#171) +- Add a new ``--version`` command line flag to show the version of pystack (#203) +- Support debugging free-threading (a.k.a. "nogil") Python 3.13 builds. Note that PyStack can't itself be run with ``python3.13t``, it can only attach to a ``python3.13t`` process or core file from another interpreter. (#206) + + +Bug Fixes +~~~~~~~~~ + +- Fix a bug that was causing Python scripts executed directly via shebang to report the shell script as the executable. (#184) +- Heap corruption could cause PyStack to fail to generate a stack when ``--locals`` mode was used. This has been fixed by falling back to a reasonable default when attempting to format the repr of a local variable causes a dereference of an invalid pointer. (#194) +- Fix a crash when analysing processes where the eval loop has a shim frame at the bottom of the stack (#204) + + pystack 1.3.0 (2023-11-28) -------------------------- diff --git a/README.md b/README.md index a6df6fe3..a949aa7e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ PyStack has the following amazing features: - ๐Ÿงต Shows if each thread currently holds the Python GIL, is waiting to acquire it, or is currently dropping it. - ๐Ÿ—‘๏ธ Shows if a thread is running a garbage collection cycle. +- ๐Ÿช† Reports on multiple interpreters in the same process. - ๐Ÿ Optionally shows native function calls, as well as Python ones. In this mode, PyStack prints the native stack trace (C/C++/Rust function calls), except that the calls to Python callables are replaced with frames showing the Python code being executed, instead of showing the internal C @@ -84,18 +85,17 @@ cd pystack python3 -m venv ../pystack-env/ # just an example, put this wherever you want source ../pystack-env/bin/activate python3 -m pip install --upgrade pip -python3 -m pip install -e . -python3 -m pip install -r requirements-test.txt -r requirements-extra.txt +python3 -m pip install -e . --group test --group extra ``` This will install PyStack in the virtual environment in development mode (the `-e` of the last `pip install` command), and then install the Python libraries needed to test it, lint it, and generate its documentation. -If you plan to contribute back, you should install the pre-commit hooks: +If you plan to contribute back, you should install the prek (pre-commit) hooks: ```shell -pre-commit install +prek install ``` This will ensure that your contribution passes our linting checks. @@ -278,3 +278,5 @@ You must use your real name (sorry, no pseudonyms, and no anonymous contribution - Submit the Issue. - Submit a Pull Request and link it to the Issue by including "#" in the Pull Request summary. + +Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for more info. diff --git a/build_scripts/elfutils-aarch64-signal-frame.patch b/build_scripts/elfutils-aarch64-signal-frame.patch new file mode 100644 index 00000000..85b36611 --- /dev/null +++ b/build_scripts/elfutils-aarch64-signal-frame.patch @@ -0,0 +1,100 @@ +This patch allows elfutils to unwind through AArch64 signal frames on Linux. + +diff --git a/backends/aarch64_unwind.c b/backends/aarch64_unwind.c +index e0a7e96..d276d92 100644 +--- a/backends/aarch64_unwind.c ++++ b/backends/aarch64_unwind.c +@@ -40,16 +40,91 @@ + + #include "libebl_CPU.h" + ++/* Linux AArch64 rt_sigreturn ABI. See kernel/vdso/sigreturn.S, ++ kernel/signal.c and include/uapi/asm/{ucontext,sigcontext}.h under: ++ https://github.com/torvalds/linux/tree/v6.6/arch/arm64 ++ ++ mov x8, #139 (0xd2801168) followed by svc #0 (0xd4000001) forms ++ RT_SIGRETURN_WORD on little-endian systems. The frame offsets are: ++ ++ mcontext = 128 + 8 + 8 + 24 + 128 + 8 = 0x130 ++ regs = 8, sp = 8 + 31 * 8 = 0x100, pc = sp + 8 = 0x108. ++ */ ++#define RT_SIGRETURN_WORD UINT64_C (0xd4000001d2801168) ++#define RT_SIGFRAME_SIGCONTEXT_OFFSET 0x130 ++#define SIGCONTEXT_REGS_OFFSET 0x8 ++#define SIGCONTEXT_SP_OFFSET 0x100 ++#define SIGCONTEXT_PC_OFFSET 0x108 ++ ++static bool ++unwind_signal_frame (Ebl *ebl, Dwarf_Addr pc, ++ ebl_tid_registers_t *setfunc, ++ ebl_tid_registers_get_t *getfunc, ++ ebl_pid_memory_read_t *readfunc, void *arg, ++ bool *signal_framep) ++{ ++ /* Do not guess at the Linux AArch64 big-endian signal ABI here. */ ++ if (ebl->data != ELFDATA2LSB) ++ return false; ++ ++ /* frame_unwind.c normally passes PC - 1 for a non-activation frame. */ ++ if ((pc & 3) == 3) ++ ++pc; ++ else if ((pc & 3) != 0) ++ return false; ++ ++ Dwarf_Word instructions; ++ if (!readfunc (pc, &instructions, arg) ++ || instructions != RT_SIGRETURN_WORD) ++ return false; ++ ++ Dwarf_Word signal_sp; ++ if (!getfunc (SP_REG, 1, &signal_sp, arg)) ++ return false; ++ ++ const Dwarf_Addr sigcontext ++ = signal_sp + RT_SIGFRAME_SIGCONTEXT_OFFSET; ++ Dwarf_Word registers[31]; ++ Dwarf_Word saved_sp; ++ Dwarf_Word saved_pc; ++ ++ /* Read the whole context before changing the tentative caller state. */ ++ for (unsigned int regno = 0; regno < 31; ++regno) ++ if (!readfunc (sigcontext + SIGCONTEXT_REGS_OFFSET ++ + regno * sizeof (Dwarf_Word), ++ ®isters[regno], arg)) ++ return false; ++ ++ if (!readfunc (sigcontext + SIGCONTEXT_SP_OFFSET, &saved_sp, arg) ++ || !readfunc (sigcontext + SIGCONTEXT_PC_OFFSET, &saved_pc, arg) ++ || saved_pc == 0 ++ || saved_sp == 0 ++ || (saved_sp & 15) != 0) ++ return false; ++ ++ if (!setfunc (0, 31, registers, arg) ++ || !setfunc (SP_REG, 1, &saved_sp, arg) ++ || !setfunc (-1, 1, &saved_pc, arg)) ++ return false; ++ ++ *signal_framep = true; ++ return true; ++} ++ + /* There was no CFI. Maybe we happen to have a frame pointer and can unwind from that? */ + + bool +-EBLHOOK(unwind) (Ebl *ebl __attribute__ ((unused)), Dwarf_Addr pc __attribute__ ((unused)), ++EBLHOOK(unwind) (Ebl *ebl, Dwarf_Addr pc, + ebl_tid_registers_t *setfunc, ebl_tid_registers_get_t *getfunc, + ebl_pid_memory_read_t *readfunc, void *arg, +- bool *signal_framep __attribute__ ((unused))) ++ bool *signal_framep) + { + Dwarf_Word fp, lr, sp; + ++ if (unwind_signal_frame (ebl, pc, setfunc, getfunc, readfunc, arg, ++ signal_framep)) ++ return true; ++ + if (!getfunc(LR_REG, 1, &lr, arg)) + return false; + diff --git a/build_scripts/elfutils_contiguous_segments.diff b/build_scripts/elfutils_contiguous_segments.diff deleted file mode 100644 index 37b1a4dd..00000000 --- a/build_scripts/elfutils_contiguous_segments.diff +++ /dev/null @@ -1,66 +0,0 @@ -diff --git a/libdwfl/dwfl_segment_report_module.c b/libdwfl/dwfl_segment_report_module.c -index 3ef62a7d..09ee37b3 100644 ---- a/libdwfl/dwfl_segment_report_module.c -+++ b/libdwfl/dwfl_segment_report_module.c -@@ -737,17 +737,34 @@ dwfl_segment_report_module (Dwfl *dwfl, int ndx, const char *name, - && invalid_elf (module->elf, module->disk_file_has_build_id, - &build_id)) - { -- elf_end (module->elf); -- close (module->fd); -- module->elf = NULL; -- module->fd = -1; -+ /* If MODULE's build-id doesn't match the disk file's -+ build-id, close ELF only if MODULE and ELF refer to -+ different builds of files with the same name. This -+ prevents premature closure of the correct ELF in cases -+ where segments of a module are non-contiguous in memory. */ -+ if (name != NULL && module->name[0] != '\0' -+ && strcmp (basename (module->name), basename (name)) == 0) -+ { -+ elf_end (module->elf); -+ close (module->fd); -+ module->elf = NULL; -+ module->fd = -1; -+ } - } -- if (module->elf != NULL) -+ else if (module->elf != NULL) - { -- /* Ignore this found module if it would conflict in address -- space with any already existing module of DWFL. */ -+ /* This module has already been reported. */ - skip_this_module = true; - } -+ else -+ { -+ /* Only report this module if we haven't already done so. */ -+ for (Dwfl_Module *mod = dwfl->modulelist; mod != NULL; -+ mod = mod->next) -+ if (mod->low_addr == module_start -+ && mod->high_addr == module_end) -+ skip_this_module = true; -+ } - } - if (skip_this_module) - goto out; -@@ -781,10 +798,6 @@ dwfl_segment_report_module (Dwfl *dwfl, int ndx, const char *name, - } - } - -- /* Our return value now says to skip the segments contained -- within the module. */ -- ndx = addr_segndx (dwfl, segment, module_end, true); -- - /* Examine its .dynamic section to get more interesting details. - If it has DT_SONAME, we'll use that as the module name. - If it has a DT_DEBUG, then it's actually a PIE rather than a DSO. -@@ -929,6 +942,8 @@ dwfl_segment_report_module (Dwfl *dwfl, int ndx, const char *name, - ndx = -1; - goto out; - } -+ else -+ ndx++; - - /* We have reported the module. Now let the caller decide whether we - should read the whole thing in right now. */ diff --git a/docs/Makefile b/docs/Makefile index e718d771..e76826bb 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,6 +1,8 @@ # Minimal makefile for Sphinx documentation # +SPHINXAUTOBUILD = sphinx-autobuild + # You can set these variables from the command line. SPHINXOPTS = -n -W --keep-going SPHINXBUILD = sphinx-build @@ -22,3 +24,13 @@ html: $(SPHINXBUILD) -W -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +.PHONY: man +man: + $(SPHINXBUILD) -b man -D exclude_patterns= $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The man pages are in $(BUILDDIR)/man." + +.PHONY: livehtml +livehtml: + $(SPHINXAUTOBUILD) -a $(ALLSPHINXOPTS) $(BUILDDIR)/html diff --git a/docs/corefile.rst b/docs/corefile.rst index d1c387d8..2f61bd55 100644 --- a/docs/corefile.rst +++ b/docs/corefile.rst @@ -37,6 +37,8 @@ In most cases, you just need to provide the location of the core to use PyStack (Python) File "/test.py", line 16, in third_func time.sleep(1000) +Pystack can automatically extract core dumps from `.gz` files: `pystack core ./archived_core_file.gz` + To learn more about the different options you can use to customize what is reported, check the :ref:`customizing-the-reports` section. Providing the executable diff --git a/docs/multiple_interpreters.rst b/docs/multiple_interpreters.rst new file mode 100644 index 00000000..703731e0 --- /dev/null +++ b/docs/multiple_interpreters.rst @@ -0,0 +1,108 @@ +.. _multiple-interpreters: + +Multiple interpreters +********************* + +If your process uses multiple interpreters (for example, using the `concurrent.interpreters` module +or a `concurrent.futures.InterpreterPoolExecutor`), PyStack will show which interpreter each stack +is associated with, and will even show where a thread switches from one interpreter to another. + +.. note:: + This feature only works for processes running Python 3.11 or newer. + +Example program with multiple interpreters +========================================== + +For instance, given this Python 3.14 program:: + + import os + import signal + from concurrent.futures import InterpreterPoolExecutor + + + def interpreter1_body(read_fd): + print("Hello from interpreter 1!") + open(read_fd, closefd=False).read() + print("Goodbye from interpreter 1!") + + + def interpreter2_body(read_fd): + print("Greetings from interpreter 2!") + open(read_fd, closefd=False).read() + print("Farewell from interpreter 2!") + + + read_fd, write_fd = os.pipe() + + with InterpreterPoolExecutor(max_workers=2) as executor: + executor.submit(interpreter1_body, read_fd) + executor.submit(interpreter2_body, read_fd) + + try: + signal.pause() + except KeyboardInterrupt: + print("\nCtrl-C received, signaling workers to stop...") + os.close(write_fd) + os.close(read_fd) + +If you run it until it pauses and then analyze it with PyStack, you will see output like this:: + + $ pystack remote 392462 + Traceback for thread 392464 (InterpreterPool) (most recent call last): + In the main interpreter [] + (Python) File "/usr/lib/python3.14/threading.py", line 1044, in _bootstrap + self._bootstrap_inner() + (Python) File "/usr/lib/python3.14/threading.py", line 1082, in _bootstrap_inner + self._context.run(self.run) + (Python) File "/usr/lib/python3.14/threading.py", line 1024, in run + self._target(*self._args, **self._kwargs) + (Python) File "/usr/lib/python3.14/concurrent/futures/thread.py", line 119, in _worker + work_item.run(ctx) + (Python) File "/usr/lib/python3.14/concurrent/futures/thread.py", line 86, in run + result = ctx.run(self.task) + (Python) File "/usr/lib/python3.14/concurrent/futures/interpreter.py", line 84, in run + return self.interp.call(do_call, self.results, *task) + (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 238, in call + return self._call(callable, args, kwargs) + (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 220, in _call + res, excinfo = _interpreters.call(self._id, callable, args, kwargs, restrict=True) + In interpreter 2 [] + (Python) File "/usr/lib/python3.14/concurrent/futures/interpreter.py", line 11, in do_call + return func(*args, **kwargs) + (Python) File "/tmp/interpreter_demo.py", line 14, in interpreter2_body + open(read_fd, closefd=False).read() + + Traceback for thread 392463 (InterpreterPool) (most recent call last): + In the main interpreter [] + (Python) File "/usr/lib/python3.14/threading.py", line 1044, in _bootstrap + self._bootstrap_inner() + (Python) File "/usr/lib/python3.14/threading.py", line 1082, in _bootstrap_inner + self._context.run(self.run) + (Python) File "/usr/lib/python3.14/threading.py", line 1024, in run + self._target(*self._args, **self._kwargs) + (Python) File "/usr/lib/python3.14/concurrent/futures/thread.py", line 119, in _worker + work_item.run(ctx) + (Python) File "/usr/lib/python3.14/concurrent/futures/thread.py", line 86, in run + result = ctx.run(self.task) + (Python) File "/usr/lib/python3.14/concurrent/futures/interpreter.py", line 84, in run + return self.interp.call(do_call, self.results, *task) + (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 238, in call + return self._call(callable, args, kwargs) + (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 220, in _call + res, excinfo = _interpreters.call(self._id, callable, args, kwargs, restrict=True) + In interpreter 1 [] + (Python) File "/usr/lib/python3.14/concurrent/futures/interpreter.py", line 11, in do_call + return func(*args, **kwargs) + (Python) File "/tmp/interpreter_demo.py", line 8, in interpreter1_body + open(read_fd, closefd=False).read() + + Traceback for thread 392462 (python3.14) (most recent call last): + In the main interpreter [] + (Python) File "/tmp/interpreter_demo.py", line 25, in + signal.pause() + +Note that the output now shows which interpreter each thread is running in, and also shows the +transition from the main interpreter to the subinterpreters. The GIL status is shown separately for +each interpreter, as a thread may hold the GIL for one interpreter but not another. Likewise, the GC +status is shown separately, since a thread may be running a GC cycle in one interpreter but not +another. diff --git a/docs/overview.rst b/docs/overview.rst index cfbb38cd..b3a26c3d 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -14,6 +14,7 @@ PyStack has the following amazing features: - ๐Ÿงต Shows if each thread currently holds the Python GIL, is waiting to acquire it, or is currently dropping it. - ๐Ÿ—‘๏ธ Shows if a thread is running a garbage collection cycle. +- ๐Ÿช† Reports on multiple interpreters in the same process. - ๐Ÿ Optionally shows native function calls, as well as Python ones. In this mode, PyStack prints the native stack trace (C/C++/Rust function calls), except that the calls to Python callables are replaced with frames showing the Python code being executed, instead of showing the internal C @@ -42,13 +43,25 @@ Contents process corefile + multiple_interpreters customizing_the_reports .. toctree:: :hidden: :caption: Project Information - changelog +.. toctree:: + :hidden: + :caption: Hands-on Tutorial + + tutorials/deadlock + tutorials/random_prime_number + tutorials/core_tutorial + +.. toctree:: + :caption: Project Information + + changelog Indices and tables diff --git a/docs/tutorials/core_tutorial.py b/docs/tutorials/core_tutorial.py new file mode 100644 index 00000000..2b2b6c46 --- /dev/null +++ b/docs/tutorials/core_tutorial.py @@ -0,0 +1,20 @@ +import gc + + +def types_found_in_tuples(): + elem_types = set() + + for obj in gc.get_objects(): + if isinstance(obj, tuple): + elem_types.update(map(type, obj)) + + for elem_type in elem_types: + yield elem_type + + +# printing all results with multiple calls to print +for t in types_found_in_tuples(): + print(t) + +# printing all results with one call to print +print(*types_found_in_tuples(), sep="\n") diff --git a/docs/tutorials/core_tutorial.rst b/docs/tutorials/core_tutorial.rst new file mode 100644 index 00000000..7bf87170 --- /dev/null +++ b/docs/tutorials/core_tutorial.rst @@ -0,0 +1,287 @@ +Debugging Core Files +==================== + +Intro +----- + +This lesson is meant to familiarize you with using the ``pystack core`` subcommand to analyze a core +dump file. The steps will help you enable core dumps in your Linux environment, run a script that +crashes and leaves a core dump, and then analyze the core dump file with PyStack. + +Ensuring core dumps are enabled +------------------------------- + +First, we need to ensure that crashing processes in our Linux environment leave core files behind. + +The system allows setting a limit on the size of a core dump, to avoid filling up your disk when +a process is crashing frequently. You can check the configured size limit by running: + +.. code:: shell + + ulimit -c + +If that reports ``0`` it means that core files have been disabled on your machine, and you'll need +to enable them before proceeding with this tutorial. You can enable them using the ``ulimit`` +command in your terminal: + +.. code:: shell + + # unlimited core size + ulimit -c unlimited + + # or if you want to limit the size to 100 MB instead + ulimit -c 100000 + +Note that this command only enables core files for the shell session you ran the command in. If you +want to make this change permanent, you can add that line to your ``~/.bashrc`` or ``~/.zshrc`` file +depending on your terminal preference. For instance: + +.. code:: shell + + echo "ulimit -c unlimited" >> ~/.bashrc + source ~/.bashrc + +Next, we want to check where core dump files will be written. Check what this command prints: + +.. code:: shell + + cat /proc/sys/kernel/core_pattern + +That may contain placeholders, prefixed with ``%``. You can learn more about what placeholders are +available in the "Naming of core dump files" section of `man 5 core `_. + +If you want to control the names used for generated core files, you can update the ``core_pattern`` +setting with ``sysctl``. Note that this affects all users and processes on the machine! The naming +of core dump files can be configured like so: + +.. code:: shell + + sudo sysctl -w kernel.core_pattern="/tmp/core-%e.%p.%h.%t" + +In this example, we configure the file pattern to include the executable name (``%e``), process ID +(``%p``), hostname (``%h``), and timestamp (``%t``), and to be written to the ``/tmp`` directory. If +you would like to learn more about core dumps, Red Hat has more information `here +`_. + +Generating the Core Dump +------------------------ + +You can test whether core dumps are being generated by running this command: + +.. code:: shell + + python3 -c 'import os; os.abort()' + +If everything is configured properly, you should see a message telling you that the process aborted +and a core file was dumped. If your shell is bash, that might say simply ``Aborted (core dumped)``. +If you use zsh, you'd see something like ``zsh: abort python3 -c 'import os; os.abort()'``. + +Now, we need to find the core file. Given the ``core_pattern`` that we configured above with +``sysctl``, the core dump should have been written to ``/tmp`` and its name should start with +``core-``, so you can find the core file with: + +.. code:: shell + + ls /tmp/core-* + +Analyzing the Core Dump +----------------------- + +Now that we have generated the core dump file and located it on disk, we can use the ``pystack +core`` command to analyze it like so: + +.. code:: shell + + pystack core /tmp/core-... + +The output will display the stack trace of the core dump file, which will help you identify the +source of the error. In the case of explicitly telling the process to abort itself, that stack isn't +all that interesting: + +.. code:: python + + Using executable found in the core file: /src/.venv/bin/python + + Core file information: + state: R zombie: True niceness: 0 + pid: 618 ppid: 473 sid: 473 + uid: 1000 gid: 1000 pgrp: 618 + executable: python3 arguments: python3 -c import os; os.abort() + + The process died due receiving signal SIGABRT + Traceback for thread 618 [Has the GIL] (most recent call last): + (Python) File "", line 1, in + +By default PyStack shows you the same Python stack as the interpreter would print if an exception +occurred. Since the Python stack only has one frame on it, there's not much to see here! For +debugging a crash, though, it's generally best to use the ``--native`` flag to display the +interpreter's native C frames, as well as any C or C++ or Rust frames from libraries you are using, +interleaved with the Python frames in the resulting stack trace. + +.. code:: shell + + pystack core --native /tmp/core-... + +This will produce output similar to the below: + +.. code:: python + + Using executable found in the core file: /src/.venv/bin/python + + Core file information: + state: R zombie: True niceness: 0 + pid: 618 ppid: 473 sid: 473 + uid: 1000 gid: 1000 pgrp: 618 + executable: python3 arguments: python3 -c import os; os.abort() + + The process died due receiving signal SIGABRT + Traceback for thread 618 [Has the GIL] (most recent call last): + (C) File "???", line 0, in _start (python3) + (C) File "../csu/libc-start.c", line 360, in __libc_start_main@@GLIBC_2.34 (libc.so.6) + (C) File "../sysdeps/nptl/libc_start_call_main.h", line 58, in __libc_start_call_main (libc.so.6) + (C) File "???", line 0, in Py_BytesMain (python3) + (C) File "???", line 0, in Py_RunMain (python3) + (C) File "???", line 0, in PyRun_SimpleStringFlags (python3) + (C) File "???", line 0, in PyRun_StringFlags (python3) + (Python) File "", line 1, in + (C) File "./stdlib/abort.c", line 79, in abort (libc.so.6) + (C) File "../sysdeps/posix/raise.c", line 26, in raise (libc.so.6) + (C) File "./nptl/pthread_kill.c", line 89, in pthread_kill@@GLIBC_2.34 (libc.so.6) + (C) File "./nptl/pthread_kill.c", line 78, in __pthread_kill_internal (inlined) (libc.so.6) + (C) File "./nptl/pthread_kill.c", line 44, in __pthread_kill_implementation (inlined) (libc.so.6) + +Note that this now includes not only the Python frames on the stack, but also the C frames that +led to the first Python call on the stack, as well as the C frames that the last Python call on the +stack called into. This lets us see how the interpreter implements the ``os.abort()`` function, as +well as how the C library (glibc) implements its own ``abort()`` function! + +Exercise +-------- + +Read through the code in ``core_tutorial.py`` and see if you can spot any bugs: + +.. literalinclude:: core_tutorial.py + :linenos: + +Given the context of this exercise, you won't be particularly surprised to learn that this code +crashes, and leaves a core file for you to investigate. Spend a few minutes investigating the +``pystack core --native`` report for the core file generated by running this script, and see if you +can figure out what's going wrong. If you aren't familiar with Python's internals this will be +tough, but these hints should help: + +.. raw:: html + +
+ Hint 1 + +This program uses `gc.get_objects` to find every tuple in the program's memory, and then finds the +set of distinct element types contained in tuples. The ``for`` loop on lines 16 and 17 runs +successfully to completion and produces a usable report. We know that it finishes because the +PyStack report shows that the crash happens inside the call on line 20. + +Printing each type one at a time in a loop ought to be equivalent to telling ``print()`` to print +them all at once with newlines between each. Why isn't it? + +.. raw:: html + +
+
+ Hint 2 + +Check the local variables with ``--locals`` as well. Can you spot anything wrong there? + +.. raw:: html + +
+
+ Hint 3 + +``--native`` mode shows us that the crash happens within a call to :c:func:`PySequence_Tuple`. Read +up on what that function does, and see if that gives you a clue about what's going wrong here. + +.. raw:: html + +
+
+ Hint 4 + +The documentation tells us that :c:func:`PySequence_Tuple` can be called on either a sequence or an +iterable. Can you tell from the stack what type of object it was being called on when the crash +happened? + +.. raw:: html + +
+
+ Hint 5 + +What gets printed out if you run this Python code? + +.. code:: python + + def args_type(*args): + print(type(args)) + + args_type() + +.. raw:: html + +
+ +Solution +-------- + +.. raw:: html + +
+ Toggle to see the explanation + +Just by using ``--native`` mode, we see some interesting things. First, the crash doesn't happen +when we loop over the values yielded by ``types_found_in_tuples()`` with a ``for`` loop, but does +happen when we pass all the yielded values to ``print`` using ``*`` for unpacking. Second, the crash +happens inside of a call to :c:func:`PySequence_Tuple`, which is the C function equivalent to the +Python code ``tuple(iterable)``. + +When we add ``--locals`` into the mix, we additionally see the very surprising value of ``obj``: + +.. code:: text + + obj: (, , ...) + +That's PyStack's representation of a tuple whose first two elements are `NULL pointers +`_! + +This is about as much as PyStack can tell us, and from here we need to figure things out on our own. +But all the clues are there: + +1. ``types_found_in_tuples()`` was called into by :c:func:`PySequence_Tuple`, which constructs a new + tuple from an iterable +2. ``types_found_in_tuples()`` went on to discover a tuple that contains NULL pointers +3. The problem only happens when we use ``*`` unpacking in the call to ``print``, and doesn't happen + when we use a ``for`` loop +4. Functions receive ``*args`` as a tuple! + +What's happening here is that, in order to execute ``print(*types_found_in_tuples())``, the +interpreter needs to create a new tuple to pass to ``print``, which will contain all of the values +yielded by ``types_found_in_tuples()``. The interpreter creates that tuple, and then starts +iterating over the generator produced by calling ``types_found_in_tuples()`` in order to populate +that new tuple and get it ready to pass to ``print``. But ``types_found_in_tuples()`` uses +`gc.get_objects` and discovers this new, partially initialized tuple, and chaos ensues when it tries +to use that object before the interpreter has finished assigning its elements. + +.. raw:: html + +
+ + +Conclusion +---------- + +In this tutorial, we learned how to use the ``core`` subcommand to get stack traces from core dumps. +We learned how to ensure core files are produced, and how to test that. We then analyzed the core +dump file using the ``pystack`` command to identify the source of the fault. Using the ``--native`` +flag, we were able to view what the Python interpreter itself was doing at the time of the crash, +which provided further insights into the cause of the error. By understanding how to analyze core +dump files, we can effectively debug and troubleshoot interpreter crashes. + +Thank you for following along with this tutorial. Happy coding! diff --git a/docs/tutorials/deadlock.py b/docs/tutorials/deadlock.py new file mode 100644 index 00000000..48d38cdb --- /dev/null +++ b/docs/tutorials/deadlock.py @@ -0,0 +1,30 @@ +import os +import threading +import time + + +def background(first_lock, second_lock): + with first_lock: + print(" First lock acquired") + time.sleep(1) + with second_lock: + print(" Second lock acquired") + + +if __name__ == "__main__": + print(f"Process ID: {os.getpid()}") + lock_a = threading.Lock() + lock_b = threading.Lock() + + t1 = threading.Thread(target=background, args=(lock_a, lock_b)) + t2 = threading.Thread(target=background, args=(lock_b, lock_a)) + + print("Starting First Thread") + t1.start() + print("Starting Second Thread") + t2.start() + + t1.join() + t2.join() + + print("Finished execution") diff --git a/docs/tutorials/deadlock.rst b/docs/tutorials/deadlock.rst new file mode 100644 index 00000000..30135707 --- /dev/null +++ b/docs/tutorials/deadlock.rst @@ -0,0 +1,237 @@ +Deadlock +======== + +Intro +----- + +This lesson is meant to familiarize you with PyStack with a classic problem: lock acquisition. + +In this exercise, we will intentionally create a lock ordering issue, which is a common way of +causing a deadlock, where two or more threads are all waiting for the others to release resources, +causing the program to hang indefinitely. + +Development Environment Setup +----------------------------- + +Navigate to the `PyStack GitHub repo `_ and get a copy of the +source code. You can either clone it, or just download the zip, whatever is your preference here. + +You will also need a terminal with a reasonably recent version of ``python3`` installed. + +Once you have the repo ready, ``cd`` into the ``docs/tutorials`` folder: + +.. code:: shell + + cd docs/tutorials/ + +It is here where we will be running the tests and exercises for the remainder of the tutorial. + +Let's go ahead and setup our virtual environment. For reference, here are the official `python3 venv +docs `_. You can also just follow along with the +commands below. + +.. code:: shell + + python3 -m venv .venv + +Once your virtual environment has been created, you can activate it like so: + +.. code:: shell + + source .venv/bin/activate + +Your terminal prompt will be prefixed with ``(.venv)`` to show that activation was successful. +With our virtual environment ready, we can go ahead and install PyStack: + +.. code:: shell + + python3 -m pip install pystack + +Keep your virtual environment activated for the rest of the tutorial, and you should be able to run +any of the commands in the exercises. + +Debugging a running process +--------------------------- + +``pystack remote`` lets you analyze the status of a running ("remote") process. + +Triggering the deadlock +^^^^^^^^^^^^^^^^^^^^^^^ + +In the ``docs/tutorials`` directory, there is a script called ``deadlock.py``: + +.. literalinclude:: deadlock.py + :linenos: + +Since we navigated to that directory above, we can run the deadlock script with: + +.. code:: shell + + python3 deadlock.py & + +This script will intentionally deadlock. The ``&`` causes the process to be run in the background, +so that you're still able to run commands in the current terminal once it has deadlocked. The output +will contain the process ID, so this is the expected output: + +.. code:: shell + + Process ID: + Starting First Thread + First lock acquired + Starting Second Thread + First lock acquired + +You could also find the PID with: + +.. code:: shell + + ps aux | grep deadlock.py + +After the deadlock occurs we can use the ``pystack`` command to analyze the process (replace +```` with the process ID from the previous step): + +.. code:: shell + + pystack remote + +If you see ``Operation not permitted``, you may need to instead run it with: + +.. code:: shell + + sudo -E pystack remote + + +Understanding the results +^^^^^^^^^^^^^^^^^^^^^^^^^ + +The expected result is output similar to this: + +.. code:: python + + Traceback for thread 789 (python3) [] (most recent call last): + (Python) File "//threading.py", line 966, in _bootstrap + self._bootstrap_inner() + (Python) File "//threading.py", line 1009, in _bootstrap_inner + self.run() + (Python) File "//threading.py", line 946, in run + self._target(*self._args, **self._kwargs) + (Python) File "//deadlock.py", line 10, in background + with second_lock: + + Traceback for thread 456 (python3) [] (most recent call last): + (Python) File "//threading.py", line 966, in _bootstrap + self._bootstrap_inner() + (Python) File "//threading.py", line 1009, in _bootstrap_inner + self.run() + (Python) File "//threading.py", line 946, in run + self._target(*self._args, **self._kwargs) + (Python) File "//deadlock.py", line 10, in background + with second_lock: + + Traceback for thread 123 (python3) [] (most recent call last): + (Python) File "//deadlock.py", line 27, in + t1.join() + (Python) File "//threading.py", line 1089, in join + self._wait_for_tstate_lock() + (Python) File "//threading.py", line 1109, in _wait_for_tstate_lock + if lock.acquire(block, timeout): + +Notice that each section is displaying a running thread, and there are three threads here: + +1. Thread 123 is the original thread that creates the other two, and waits for them +2. Thread 456 is ``t1`` +3. Thread 789 is ``t2`` + +Each thread has a stack trace: + +- The thread 789 is trying to acquire ``lock_a`` but is blocked because ``lock_a`` is already held + by thread 456. +- The thread 456 is trying to acquire ``lock_b`` but is blocked because ``lock_b`` is already held + by thread 789. +- The main thread 123 is calling ``join()`` on ``t1``, waiting for it to finish. However, ``t1`` + cannot finish because it is stuck waiting for ``t2``. + +We can see that this is a deadlock: every thread is willing to wait forever for some condition that +will never happen, due to the improper lock acquisition ordering. + +Exploring more features +^^^^^^^^^^^^^^^^^^^^^^^ + +PyStack has some features that can help us diagnose the problem. Using ``--locals`` you can obtain +a simple string representation of the local variables in the different frames as well as the +function arguments. + +When you run: + +.. code:: shell + + pystack remote --locals + +The expected result is: + +.. code:: shell + + Traceback for thread 789 (python3) [] (most recent call last): + (Python) File "//threading.py", line 966, in _bootstrap + self._bootstrap_inner() + Arguments: + self: + (Python) File "//threading.py", line 1009, in _bootstrap_inner + self.run() + Arguments: + self: + (Python) File "//threading.py", line 946, in run + self._target(*self._args, **self._kwargs) + Arguments: + self: + (Python) File "//deadlock.py", line 10, in background + with second_lock: + Arguments: + second_lock: <_thread.lock at 0x7f0c04b90900> + first_lock: <_thread.lock at 0x7f0c04b90b40> + + Traceback for thread 456 (python3) [] (most recent call last): + (Python) File "//threading.py", line 966, in _bootstrap + self._bootstrap_inner() + Arguments: + self: + (Python) File "//threading.py", line 1009, in _bootstrap_inner + self.run() + Arguments: + self: + (Python) File "//threading.py", line 946, in run + self._target(*self._args, **self._kwargs) + Arguments: + self: + (Python) File "//deadlock.py", line 10, in background + with second_lock: + Arguments: + second_lock: <_thread.lock at 0x7f0c04b90b40> + first_lock: <_thread.lock at 0x7f0c04b90900> + + Traceback for thread 123 (python3) [] (most recent call last): + (Python) File "//deadlock.py", line 28, in + t1.join() + (Python) File "//threading.py", line 1089, in join + self._wait_for_tstate_lock() + Arguments: + timeout: None + self: + (Python) File "//threading.py", line 1109, in _wait_for_tstate_lock + if lock.acquire(block, timeout): + Arguments: + timeout: -1 + block: True + self: + Locals: + lock: <_thread.lock at 0x7f0c04bae100> + +Observe that we have the same format of result, with one section for each thread. +However now, there is now more information: the local variables and function arguments. + +- In thread 789 and 456 we can identify the ID of each lock. +- In the main thread 123 we can verify the arguments of ``lock.acquire()``, and see that no timeout + was set (``timeout: None``) and that ``self`` refers to the thread object ````. Moreover, in ``_wait_for_tstate_lock`` we see that ``timeout`` is ``-1``, which + represents an indefinite ``wait``, and ``block`` is ``True``, meaning it will block until the lock + is acquired. diff --git a/docs/tutorials/random_prime_number.py b/docs/tutorials/random_prime_number.py new file mode 100644 index 00000000..d435b80e --- /dev/null +++ b/docs/tutorials/random_prime_number.py @@ -0,0 +1,54 @@ +import random +from math import sqrt + + +def is_prime(number, *, _factor=None): + """ + Check whether the given number is prime. + + Returns: + True if prime, False if composite. + """ + # If we weren't called recursively, generate the initial factor to check + if _factor is None: + _factor = int(sqrt(number)) + 1 + + # Prime number must be integers + if type(number) is not int: + return False + + # If we reached 1 without finding another divisor, it's prime! + if _factor == 1: + return True + + # If any other factor evenly goes into the number, it is not prime + if number % _factor == 0: + return False + + # Recursively check with smaller factor + return is_prime(number, _factor=_factor - 1) + + +def generate_num(): + """ + Generates a random integer + + Returns: + A 2-tuple containing the randomly generated number + and whether or not it's prime. + """ + number = random.random() + return number, is_prime(number) + + +def main(): + # Keep generating random numbers until we find a prime. + number, prime = generate_num() + while not prime: + number, prime = generate_num() + + print(f"The prime number generated is: {number}") + + +if __name__ == "__main__": + main() diff --git a/docs/tutorials/random_prime_number.rst b/docs/tutorials/random_prime_number.rst new file mode 100644 index 00000000..404a4b33 --- /dev/null +++ b/docs/tutorials/random_prime_number.rst @@ -0,0 +1,98 @@ +Prime Number Generation +======================= + +Intro +----- + +Error handling is essential for any programmer. Being able to understand error messages and quickly +apply accurate fixes is vital for mental health. Unfortunately, Python's stack traces don't always +give a clear picture of everything that can go wrong... + +Working Through an Example +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Let's go through an example where we create a function to identify prime numbers, and then generate +random numbers until we find a prime number validated through our function. + +Exercise +"""""""" + +Take a look at the example in ``random_prime_number.py``: do you see any errors in the code? + +.. literalinclude:: random_prime_number.py + :linenos: + +Take a guess, and then confirm by running the program. Does it work as you expected? If not, is it +obvious why your program is not running properly? + +Expectations vs Reality +""""""""""""""""""""""" + +Often an exception is thrown and the stack trace Python prints allows a quick solution to become +apparent, but sometimes we get no feedback at all. In this case, we accidentally entered an infinite +loop! But where in the program did we go wrong? Does the logic in the function ``is_prime`` work +correctly, or is the error somewhere else? + +Since the code enters an infinite loop, Python never gives us a stack trace. This can lead to a lot +of confusion and no information as to where the error occurred. We're here to see if there is +a better way to debug your infinite loop. + +Challenge +""""""""" + +Use ``pystack`` on the running program and see if you can identify the bug. Be sure to check what +options may help you locate the errors in your code. + +.. raw:: html + +
+ Hint 1 + +Check your local variables using ``pystack``. + +.. raw:: html + +
+ +.. raw:: html + +
+ Hint 2 + +Try the following command while your program is running: ``pystack remote --locals {PID}``. + +.. raw:: html + +
+ +Solutions +""""""""" + +.. raw:: html + +
+ Toggle to see the solution + +After using PyStack and printing the locals you can see that we accidentally used the wrong random +function! In our current implementation, ``random.random()`` only returns floats between [0.0, 1.0). +Our ``is_prime`` function has correct logic, but the function will never get passed a prime number, +because of the random number generator we chose! + +.. literalinclude:: random_prime_number.py + :linenos: + :pyobject: generate_num + :emphasize-lines: 9 + +A quick fix is to replace the ``random.random()`` call with ``random.randint(0, 100000)``. With this +quick fix, our program will now generate random prime numbers! + +.. raw:: html + +
+ +Conclusion +^^^^^^^^^^ + +Sometimes, finding the bug in your code is not as simple as looking at a stack trace. Infinite loops +are common logical errors in programs, but can be difficult to find. Instead of spending excessive +time searching through lots of code, using PyStack can help you find these logical errors. diff --git a/news/372.bugfix.rst b/news/372.bugfix.rst new file mode 100644 index 00000000..1792bc25 --- /dev/null +++ b/news/372.bugfix.rst @@ -0,0 +1,2 @@ +Fix native stack merging for Python 3.14 tail-call interpreter builds whose +``_TAIL_CALL_`` symbols do not have a ``.llvm.`` suffix. diff --git a/pyproject.toml b/pyproject.toml index b70c1591..a17f40b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,101 @@ [build-system] +requires = ["scikit-build-core>=0.4", "nanobind>=1.8", "nanobind<3; python_version < '3.10'"] +build-backend = "scikit_build_core.build" -requires = [ - "setuptools", - "wheel", - "Cython", - "pkgconfig" +[project] +name = "pystack" +dynamic = ["version"] +description = "Analysis of the stack of remote python processes" +readme = "README.md" +requires-python = ">=3.9" +license = {text = "Apache-2.0"} +authors = [ + {name = "Pablo Galindo Salgado"} +] +classifiers = [ + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Software Development :: Debuggers", +] + +[dependency-groups] +docs = [ + "recommonmark", + "sphinx", + "sphinx-autobuild", + "sphinx-argparse", + "furo", +] +extra = [ + "mypy", + "bump2version", + "towncrier", + "prek", + {include-group = "docs"}, +] +test = [ + "coverage[toml]", + "pyinstaller", + "pytest", + "pytest-cov", + "pytest-xdist", + "setuptools", +] + +[project.urls] +Homepage = "https://github.com/bloomberg/pystack" + +[project.scripts] +pystack = "pystack.__main__:main" + +[tool.scikit-build] +minimum-version = "0.9" +cmake.verbose = true +cmake.build-type = "RelWithDebInfo" +logging.level = "INFO" +install.strip = false +wheel.packages = ["src/pystack"] +wheel.install-dir = "pystack" +wheel.exclude = ["pystack/_pystack/**"] +# Build against the CPython Limited API / stable ABI so that CPython >= 3.12 +# non-free-threaded builds produce a single cp312-abi3 wheel. scikit-build-core +# automatically ignores this for older Pythons (< 3.12) and for free-threaded +# interpreters, which continue to produce version-specific wheels. +wheel.py-api = "cp312" +metadata.version.provider = "scikit_build_core.metadata.regex" +metadata.version.input = "src/pystack/_version.py" +sdist.include = ["src/pystack/_version.py"] +sdist.exclude = [ + ".devcontainer/**", + ".github/**", + ".vscode/**", + "CONTRIBUTING.md", + "Dockerfile", + "docs/**", + "tests/**", + "uv.lock", ] -build-backend = 'setuptools.build_meta' +[[tool.scikit-build.overrides]] +if.state = "editable" +build-dir = "build" +editable.verbose = false +cmake.build-type = "Debug" [tool.ruff] line-length = 95 -select = ["F", "E", "W", "I001"] +lint.select = ["F", "E", "W", "I001"] -[tool.ruff.isort] +[tool.ruff.lint.isort] force-single-line = true known-first-party = ["pystack"] known-third-party=["rich", "elftools", "pytest"] @@ -43,22 +125,24 @@ type = [ underlines = "-~" [tool.cibuildwheel] -build = ["cp38-*", "cp39-*", "cp310-*", "cp311-*"] +build = ["cp39-*", "cp310-*", "cp311-*", "cp312-*", "cp313-*", "cp314-*"] manylinux-x86_64-image = "manylinux2014" manylinux-i686-image = "manylinux2014" +musllinux-x86_64-image = "musllinux_1_2" skip = "*-musllinux_aarch64" [tool.cibuildwheel.linux] before-all = [ - "yum install -y python-devel-2.7.5", + "yum install -y libzstd-devel cmake patch", + "yum install -y devtoolset-10-make || true", # Install a new enough GNU make on manylinux2014 "cd /", - "VERS=0.189", + "VERS=0.195", "curl https://sourceware.org/elfutils/ftp/$VERS/elfutils-$VERS.tar.bz2 > ./elfutils.tar.bz2", "tar -xf elfutils.tar.bz2", "cd elfutils-$VERS", - "patch libdwfl/dwfl_segment_report_module.c < {package}/build_scripts/elfutils_contiguous_segments.diff", - "CFLAGS='-w' ./configure --enable-libdebuginfod=dummy --disable-debuginfod --prefix=/usr --libdir=/usr/lib64", - "make install" + "patch --batch --forward --fuzz=0 -p1 -i {package}/build_scripts/elfutils-aarch64-signal-frame.patch", + "CFLAGS='-Wno-error -g -O3' CXXFLAGS='-Wno-error -g -O3' ./configure --disable-nls --enable-libdebuginfod=dummy --disable-debuginfod --with-zstd", + 'PATH="/opt/rh/devtoolset-10/root/usr/bin/:$PATH" make install', # Ensure we pick up gnu make 4.x on manylinux2014 ] # Override the default linux before-all for musl linux @@ -69,49 +153,40 @@ before-all = [ # dependency of elfutils. "apk del gettext-dev glib-dev", - # Build musl-fts from source. This is a build dependency of elfutils, but - # isn't in the Alpine repos of the musllinux_1_1 image. The build steps come - # from https://git.alpinelinux.org/aports/tree/main/musl-fts/APKBUILD - # Setting PATH before calling boostrap.sh fixes an automake failure. I think - # the failure may be caused by a different pkg-config in /usr/local/bin. - "cd /", - "apk add --update automake autoconf libtool", - "VERS=1.2.7", - "curl -L https://github.com/void-linux/musl-fts/archive/refs/tags/v$VERS.tar.gz > ./musl-fts.tar.gz", - "tar -xf musl-fts.tar.gz", - "cd musl-fts-$VERS", - "PATH=/usr/bin:/bin ./bootstrap.sh", - "CFLAGS=-fPIC ./configure --prefix=/usr", - "make install", - # Build the latest elfutils from source. The build steps come from # https://git.alpinelinux.org/aports/tree/main/elfutils, and the need to # set the FNM_EXTMATCH macro to get the build to succeed is seen here: # https://git.alpinelinux.org/aports/tree/main/elfutils/musl-macros.patch "cd /", - "apk add --update argp-standalone bison bsd-compat-headers bzip2-dev flex-dev libtool linux-headers musl-libintl musl-obstack-dev xz-dev zlib-dev zstd-dev", - "VERS=0.189", + "apk add --update argp-standalone bison bsd-compat-headers bzip2-dev flex-dev libtool linux-headers musl-fts-dev musl-libintl musl-obstack-dev patch xz-dev zlib-dev zstd-dev cmake", + "VERS=0.195", "curl https://sourceware.org/elfutils/ftp/$VERS/elfutils-$VERS.tar.bz2 > ./elfutils.tar.bz2", "tar -xf elfutils.tar.bz2", "cd elfutils-$VERS", - "patch libdwfl/dwfl_segment_report_module.c < {package}/build_scripts/elfutils_contiguous_segments.diff", - "CFLAGS='-w -DFNM_EXTMATCH=0' ./configure --prefix=/usr --disable-nls --disable-libdebuginfod --disable-debuginfod --with-zstd", - "make install" + "patch --batch --forward --fuzz=0 -p1 -i {package}/build_scripts/elfutils-aarch64-signal-frame.patch", + "CFLAGS='-Wno-error -DFNM_EXTMATCH=0 -g -O3' CXXFLAGS='-Wno-error -g -O3' ./configure --disable-nls --enable-libdebuginfod=dummy --disable-debuginfod --with-zstd", + "make install", + + # Reinstall libintl, as the Python interpreter requires it + "apk del musl-libintl", + "apk add libintl", ] [tool.coverage.run] -plugins = [ - "Cython.Coverage", -] source = [ "src/pystack", ] branch = true parallel = true omit = [ - "stringsource", "tests/integration/*program*.py", ] [tool.coverage.report] show_missing = true + +[tool.pytest.ini_options] +# pytest retains all temp files from the last 3 test suite runs by default. +# Keep only ones for failed tests to avoid filling up a disk. +tmp_path_retention_policy = "failed" +testpaths = ["tests"] diff --git a/requirements-docs.txt b/requirements-docs.txt deleted file mode 100644 index 7df390dd..00000000 --- a/requirements-docs.txt +++ /dev/null @@ -1,5 +0,0 @@ -recommonmark -sphinx -sphinx-autobuild -sphinx-argparse -furo diff --git a/requirements-extra.txt b/requirements-extra.txt deleted file mode 100644 index 19921e61..00000000 --- a/requirements-extra.txt +++ /dev/null @@ -1,5 +0,0 @@ -mypy -bump2version -towncrier -pre-commit --r requirements-docs.txt diff --git a/requirements-test.txt b/requirements-test.txt deleted file mode 100644 index 2b4d50d0..00000000 --- a/requirements-test.txt +++ /dev/null @@ -1,6 +0,0 @@ -coverage[toml] -pyinstaller<6.0 -pytest -pytest-cov -pytest-xdist -setuptools diff --git a/setup.py b/setup.py deleted file mode 100644 index 065c119a..00000000 --- a/setup.py +++ /dev/null @@ -1,133 +0,0 @@ -import os -import pathlib -import sys -from sys import platform - -import pkgconfig -import setuptools -from Cython.Build import cythonize - -IS_LINUX = "linux" in platform - -if not IS_LINUX: - raise RuntimeError(f"pystack does not support this platform ({platform})") - -install_requires = [] - - -TEST_BUILD = False -if "--test-build" in sys.argv: - TEST_BUILD = True - sys.argv.remove("--test-build") - - -if os.getenv("CYTHON_TEST_MACROS", None) is not None: - TEST_BUILD = True - - -COMPILER_DIRECTIVES = { - "language_level": 3, - "embedsignature": True, - "boundscheck": False, - "wraparound": False, - "cdivision": True, - "c_string_type": "unicode", - "c_string_encoding": "utf8", -} - -DEFINE_MACROS = [] - -if TEST_BUILD: - COMPILER_DIRECTIVES = { - "language_level": 3, - "boundscheck": True, - "embedsignature": True, - "wraparound": True, - "cdivision": False, - "profile": True, - "linetrace": True, - "overflowcheck": True, - "infer_types": True, - "c_string_type": "unicode", - "c_string_encoding": "utf8", - } - DEFINE_MACROS.extend([("CYTHON_TRACE", "1"), ("CYTHON_TRACE_NOGIL", "1")]) - -library_flags = {"libraries": ["elf", "dw"]} - -try: - library_flags = pkgconfig.parse("libelf libdw") -except EnvironmentError as e: - print("pkg-config not found.", e) - print("Falling back to static flags.") -except pkgconfig.PackageNotFoundError as e: - print("Package Not Found", e) - print("Falling back to static flags.") - -PYSTACK_EXTENSION = setuptools.Extension( - name="pystack._pystack", - sources=[ - "src/pystack/_pystack.pyx", - "src/pystack/_pystack/corefile.cpp", - "src/pystack/_pystack/elf_common.cpp", - "src/pystack/_pystack/logging.cpp", - "src/pystack/_pystack/mem.cpp", - "src/pystack/_pystack/process.cpp", - "src/pystack/_pystack/pycode.cpp", - "src/pystack/_pystack/pyframe.cpp", - "src/pystack/_pystack/pythread.cpp", - "src/pystack/_pystack/pytypes.cpp", - "src/pystack/_pystack/unwinder.cpp", - "src/pystack/_pystack/version.cpp", - ], - language="c++", - extra_compile_args=["-std=c++17"], - extra_link_args=["-std=c++17"], - define_macros=DEFINE_MACROS, - **library_flags, -) - -PYSTACK_EXTENSION.libraries.extend(["dl", "stdc++fs"]) - - -about = {} -with open("src/pystack/_version.py") as fp: - exec(fp.read(), about) - -HERE = pathlib.Path(__file__).parent.resolve() -LONG_DESCRIPTION = (HERE / "README.md").read_text(encoding="utf-8") - -setuptools.setup( - name="pystack", - version=about["__version__"], - python_requires=">=3.7.0", - description="Analysis of the stack of remote python processes", - long_description=LONG_DESCRIPTION, - long_description_content_type="text/markdown", - url="https://github.com/bloomberg/pystack", - author="Pablo Galindo Salgado", - classifiers=[ - "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", - "Operating System :: POSIX :: Linux", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: Implementation :: CPython", - "Topic :: Software Development :: Debuggers", - ], - package_dir={"": "src"}, - packages=["pystack"], - ext_modules=cythonize( - [PYSTACK_EXTENSION], - include_path=["src/pystack"], - compiler_directives=COMPILER_DIRECTIVES, - ), - install_requires=install_requires, - include_package_data=True, - entry_points={ - "console_scripts": ["pystack=pystack.__main__:main"], - }, -) diff --git a/src/pystack/__init__.py b/src/pystack/__init__.py index e973464d..156b8f4f 100644 --- a/src/pystack/__init__.py +++ b/src/pystack/__init__.py @@ -1,7 +1,7 @@ from ._version import __version__ -from .traceback_formatter import print_thread +from .traceback_formatter import print_threads __all__ = [ "__version__", - "print_thread", + "print_threads", ] diff --git a/src/pystack/__main__.py b/src/pystack/__main__.py index ed78b0d3..4a2e844e 100644 --- a/src/pystack/__main__.py +++ b/src/pystack/__main__.py @@ -12,11 +12,14 @@ from typing import Optional from typing import Set +from pystack import __version__ from pystack.errors import InvalidPythonProcess +from pystack.process import decompress_gzip from pystack.process import is_elf +from pystack.process import is_gzip from . import errors -from . import print_thread +from . import print_threads from .colors import colored from .engine import CoreFileAnalyzer from .engine import NativeReportingMode @@ -69,7 +72,9 @@ def _exit_with_code(exception: BaseException) -> NoReturn: # pragma: no cover def produce_error_message(exception: BaseException) -> str: msg = ( - f"๐Ÿ’€ {exception} ๐Ÿ’€\n" if os.environ.get("NO_COLOR") is None else f"{exception}\n" + f"๐Ÿ’€ {exception} ๐Ÿ’€\n" + if os.environ.get("NO_COLOR") is None + else f"{exception}\n" ) if PERMISSION_ERROR_MSG in str(exception) or isinstance(exception, PermissionError): msg += PERMISSION_HELP_TEXT @@ -116,6 +121,9 @@ def generate_cli_parser() -> argparse.ArgumentParser: parser.add_argument( "-v", "--verbose", action="count", default=0, dest="global_verbose" ) + parser.add_argument( + "--version", action="version", version=__version__, help="Show version" + ) parser.add_argument( "--no-color", action="store_true", @@ -160,6 +168,15 @@ def generate_cli_parser() -> argparse.ArgumentParser: help="Include native (C) frames from threads not registered with " "the interpreter (implies --native)", ) + remote_parser.add_argument( + "--native-last", + action="store_const", + dest="native_mode", + const=NativeReportingMode.LAST, + default=NativeReportingMode.OFF, + help="Include native (C) frames only after the last python frame " + "in the resulting stack trace", + ) remote_parser.add_argument( "--locals", action="store_true", @@ -206,6 +223,15 @@ def generate_cli_parser() -> argparse.ArgumentParser: help="Include native (C) frames from threads not registered with " "the interpreter (implies --native)", ) + core_parser.add_argument( + "--native-last", + action="store_const", + dest="native_mode", + const=NativeReportingMode.LAST, + default=NativeReportingMode.OFF, + help="Include native (C) frames only after the last python frame " + "in the resulting stack trace", + ) core_parser.add_argument( "--locals", action="store_true", @@ -261,15 +287,14 @@ def process_remote(parser: argparse.ArgumentParser, args: argparse.Namespace) -> if not args.block and args.native_mode != NativeReportingMode.OFF: parser.error("Native traces are only available in blocking mode") - for thread in get_process_threads( + threads = get_process_threads( args.pid, stop_process=args.block, native_mode=args.native_mode, locals=args.locals, method=StackMethod.ALL if args.exhaustive else StackMethod.AUTO, - ): - native = args.native_mode != NativeReportingMode.OFF - print_thread(thread, native) + ) + print_threads(threads, args.native_mode) def format_psinfo_information(psinfo: Dict[str, Any]) -> str: @@ -281,15 +306,13 @@ def green(key: str) -> str: state = as_green_str(chr(psinfo["sname"])) zombie = as_green_str(bool([psinfo["zomb"]])) - return dedent( - f""" + return dedent(f""" {colored("Core file information:", "blue")} state: {state} zombie: {zombie} niceness: {green('nice')} pid: {green('pid')} ppid: {green('ppid')} sid: {green('sid')} uid: {green('uid')} gid: {green('gid')} pgrp: {green('pgrp')} executable: {green('fname')} arguments: {green('psargs')} - """ - ) + """) def format_failureinfo_information(failure_info: Dict[str, Any]) -> str: @@ -319,9 +342,31 @@ def process_core(parser: argparse.ArgumentParser, args: argparse.Namespace) -> N if not corefile.exists(): parser.error(f"Core {corefile} does not exist") + if is_gzip(corefile): + corefile = decompress_gzip(corefile) + if args.executable is None: corefile_analyzer = CoreFileAnalyzer(corefile) executable = pathlib.Path(corefile_analyzer.extract_executable()) + if not is_elf(executable): + first_map = next( + ( + map + for map in corefile_analyzer.extract_maps() + if map.path is not None + ), + None, + ) + if ( + first_map is not None + and first_map.path is not None + and is_elf(first_map.path) + ): + executable = first_map.path + LOGGER.info( + "Setting executable automatically to the first map in the core: %s", + executable, + ) if not executable.exists(): raise errors.DetectedExecutableNotFound( f"Detected executable doesn't exist: {executable}" @@ -369,16 +414,15 @@ def process_core(parser: argparse.ArgumentParser, args: argparse.Namespace) -> N elf_id if elf_id else "", ) - for thread in get_process_threads_for_core( + threads = get_process_threads_for_core( corefile, executable, library_search_path=lib_search_path, native_mode=args.native_mode, locals=args.locals, method=StackMethod.ALL if args.exhaustive else StackMethod.AUTO, - ): - native = args.native_mode != NativeReportingMode.OFF - print_thread(thread, native) + ) + print_threads(threads, args.native_mode) if __name__ == "__main__": # pragma: no cover diff --git a/src/pystack/_pystack.pyi b/src/pystack/_pystack.pyi index 36417c8a..3363f8bb 100644 --- a/src/pystack/_pystack.pyi +++ b/src/pystack/_pystack.pyi @@ -1,42 +1,70 @@ import enum import pathlib from typing import Any +from typing import Callable from typing import Dict from typing import Iterable from typing import List from typing import Optional +from typing import Set from typing import Tuple +from typing import TypeVar from typing import Union +from .maps import VirtualMap from .types import PyThread class CoreFileAnalyzer: - @classmethod - def __init__(cls, *args: Any, **kwargs: Any) -> None: ... + def __init__( + self, + core_file: Union[str, pathlib.Path], + executable: Optional[Union[str, pathlib.Path]] = None, + lib_search_path: Optional[Union[str, pathlib.Path]] = None, + ) -> None: ... def extract_module_load_points(self) -> Dict[str, int]: ... def extract_build_ids(self) -> Iterable[Tuple[str, str, str]]: ... def extract_executable(self) -> pathlib.Path: ... def extract_failure_info(self) -> Dict[str, Any]: ... - def extract_maps(self) -> Iterable[Dict[str, Any]]: ... + def extract_maps(self) -> List[VirtualMap]: ... def extract_pid(self) -> int: ... def extract_ps_info(self) -> Dict[str, Any]: ... - def missing_modules(self) -> List[str]: ... + def missing_modules(self) -> Set[pathlib.Path]: ... class NativeReportingMode(enum.Enum): - ALL: int - OFF: int - PYTHON: int + OFF = 0 + PYTHON = 1 + ALL = 1000 + LAST = 2000 class StackMethod(enum.Enum): - ALL: int - ANONYMOUS_MAPS: int - AUTO: int - BSS: int - ELF_DATA: int - HEAP: int - SYMBOLS: int + ELF_DATA = 1 + SYMBOLS = 2 + BSS = 4 + ANONYMOUS_MAPS = 8 + HEAP = 16 + DEBUG_OFFSETS = 32 + AUTO = 55 # DEBUG_OFFSETS | ELF_DATA | SYMBOLS | BSS + ALL = 63 # AUTO | ANONYMOUS_MAPS | HEAP + +class ProcessManager: + pid: int + python_version: Tuple[int, int] -class ProcessManager: ... + @classmethod + def create_from_pid( + cls, pid: int, stop_process: bool = True + ) -> "ProcessManager": ... + @classmethod + def create_from_core( + cls, + core_file: Union[str, pathlib.Path], + executable: Union[str, pathlib.Path], + lib_search_path: Optional[Union[str, pathlib.Path]] = None, + ) -> "ProcessManager": ... + def interpreter_status(self) -> int: ... + def is_interpreter_active(self) -> bool: ... + def __enter__(self) -> "ProcessManager": ... + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: ... def get_process_threads( pid: int, @@ -44,16 +72,30 @@ def get_process_threads( native_mode: NativeReportingMode = NativeReportingMode.OFF, locals: bool = False, method: StackMethod = StackMethod.AUTO, -) -> Iterable[PyThread]: ... +) -> List[PyThread]: ... def get_process_threads_for_core( - core_file: pathlib.Path, - executable: pathlib.Path, - library_search_path: Optional[str] = None, + core_file: Union[str, pathlib.Path], + executable: Union[str, pathlib.Path], + library_search_path: Optional[Union[str, pathlib.Path]] = None, native_mode: NativeReportingMode = NativeReportingMode.PYTHON, locals: bool = False, method: StackMethod = StackMethod.AUTO, -) -> Iterable[PyThread]: ... -def get_bss_info(binary: Union[str, pathlib.Path]) -> Dict[str, Any]: ... -def copy_memory_from_address( - pid: int, address: int, size: int, blocking: bool = False -) -> bytes: ... +) -> List[PyThread]: ... +def get_bss_info(binary: Union[str, pathlib.Path]) -> Optional[Dict[str, Any]]: ... +def _check_interpreter_shutdown(manager: ProcessManager) -> None: ... +def is_eval_frame(symbol: str, python_version: Tuple[int, int]) -> bool: ... +def _copy_memory_for_testing( + pid: int, + map_start: int, + map_end: int, + reads: List[Tuple[int, int]], +) -> List[bytes]: ... +def _normalize_threads_for_testing( + thread_descs: List[Dict[str, Any]], + native_mode: NativeReportingMode, + python_version: Tuple[int, int], +) -> List[PyThread]: ... + +F = TypeVar("F", bound=Callable[..., Any]) + +def intercept_runtime_errors() -> Callable[[F], F]: ... diff --git a/src/pystack/_pystack.pyx b/src/pystack/_pystack.pyx deleted file mode 100644 index a949a1ac..00000000 --- a/src/pystack/_pystack.pyx +++ /dev/null @@ -1,764 +0,0 @@ -import contextlib -import enum -import functools -import logging -import os -import pathlib -from typing import Any -from typing import Callable -from typing import Dict -from typing import Iterable -from typing import List -from typing import Optional -from typing import Set -from typing import Tuple -from typing import TypeVar - -from cython.operator import dereference -from cython.operator import postincrement - -from _pystack.corefile cimport CoreFileExtractor -from _pystack.elf_common cimport CoreFileAnalyzer as NativeCoreFileAnalyzer -from _pystack.elf_common cimport ProcessAnalyzer as NativeProcessAnalyzer -from _pystack.elf_common cimport SectionInfo -from _pystack.elf_common cimport getSectionInfo -from _pystack.logging cimport initializePythonLoggerInterface -from _pystack.mem cimport AbstractRemoteMemoryManager -from _pystack.mem cimport MemoryMapInformation as CppMemoryMapInformation -from _pystack.mem cimport ProcessMemoryManager -from _pystack.mem cimport VirtualMap as CppVirtualMap -from _pystack.process cimport AbstractProcessManager -from _pystack.process cimport CoreFileProcessManager -from _pystack.process cimport InterpreterStatus -from _pystack.process cimport ProcessManager as NativeProcessManager -from _pystack.process cimport ProcessTracer -from _pystack.process cimport remote_addr_t -from _pystack.pycode cimport CodeObject -from _pystack.pyframe cimport FrameObject -from _pystack.pythread cimport NativeThread -from _pystack.pythread cimport Thread -from _pystack.pythread cimport getThreadFromInterpreterState -from cpython.unicode cimport PyUnicode_Decode -from libcpp.memory cimport make_shared -from libcpp.memory cimport make_unique -from libcpp.memory cimport shared_ptr -from libcpp.memory cimport unique_ptr -from libcpp.string cimport string as cppstring -from libcpp.unordered_map cimport unordered_map -from libcpp.vector cimport vector - -from .errors import CoreExecutableNotFound -from .errors import EngineError -from .errors import InvalidPythonProcess -from .errors import NotEnoughInformation -from .maps import MemoryMapInformation -from .maps import VirtualMap -from .maps import generate_maps_for_process -from .maps import generate_maps_from_core_data -from .maps import parse_maps_file -from .maps import parse_maps_file_for_binary -from .process import get_python_version_for_core -from .process import get_python_version_for_process -from .process import get_thread_name -from .types import LocationInfo -from .types import NativeFrame -from .types import PyCodeObject -from .types import PyFrame -from .types import PyThread - -LOGGER = logging.getLogger(__file__) - -initializePythonLoggerInterface() - - -class StackMethod(enum.Enum): - ELF_DATA = 1 << 0 - SYMBOLS = 1 << 1 - BSS = 1 << 2 - ANONYMOUS_MAPS = 1 << 3 - HEAP = 1 << 4 - AUTO = ELF_DATA | SYMBOLS | BSS - ALL = AUTO | ANONYMOUS_MAPS | HEAP - - -class NativeReportingMode(enum.Enum): - OFF = 0 - PYTHON = 1 - ALL = 1000 - - -cdef api void log_with_python(const cppstring *message, int level) noexcept: - pymessage = _try_to_decode_string(message) - LOGGER.log(level, pymessage) - -T = TypeVar("T", bound=Callable[..., Any]) - - -class intercept_runtime_errors: - def __init__(self, exception=EngineError): - self.exception = exception - - def __call__(self, func: T) -> T: - @functools.wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> Any: - try: - return func(*args, **kwargs) - except RuntimeError as e: - raise self.exception(*e.args) from e - - return wrapper - - -@intercept_runtime_errors(EngineError) -def copy_memory_from_address(pid, address, size): - cdef shared_ptr[AbstractRemoteMemoryManager] manager - cdef int the_pid = pid - cdef vector[int] tids - manager = ( - make_shared[ProcessMemoryManager](the_pid) - ) - - cdef AbstractRemoteMemoryManager *manager_handle = manager.get() - - memory = bytearray(size) - cdef char *buffer = memory - cdef remote_addr_t _address = address - manager_handle.copyMemoryFromProcess(_address, size, buffer) - manager.reset() - return memory - - -cdef CppVirtualMap _pymap_to_map(pymap: VirtualMap) except *: - default_path = "" - assert pymap is not None - return CppVirtualMap( - pymap.start, - pymap.end, - pymap.filesize, - pymap.flags, - pymap.offset, - pymap.device, - pymap.inode, - str(pymap.path) if pymap.path else default_path, - ) - - -cdef CppMemoryMapInformation _pymapinfo_to_mapinfo(map_info: MemoryMapInformation): - interpreter_map = ( - map_info.libpython if map_info.libpython is not None else map_info.python - ) - cdef CppMemoryMapInformation cppmap_info - assert(interpreter_map is not None) - cppmap_info.setMainMap(_pymap_to_map(interpreter_map)) - if map_info.bss: - cppmap_info.setBss(_pymap_to_map(map_info.bss)) - if map_info.heap: - cppmap_info.setHeap(_pymap_to_map(map_info.heap)) - - return cppmap_info - - -cdef vector[CppVirtualMap] _pymaps_to_maps(pymaps: Iterable[VirtualMap]) except *: - cdef vector[CppVirtualMap] native_maps - for pymap in pymaps: - native_maps.push_back(_pymap_to_map(pymap)) - return native_maps - - -def get_bss_info(binary): - cdef SectionInfo _result - if getSectionInfo(str(binary), b".bss", &_result): - result = _result - return result - return None - -###################### -# MANAGEMENT CLASSES # -###################### - -cdef shared_ptr[NativeCoreFileAnalyzer] get_core_analyzer( - core_file, executable=None, lib_search_path=None -) except *: - cdef shared_ptr[NativeCoreFileAnalyzer] analyzer; - cdef cppstring the_core_file, the_executable, the_lib_search_path - the_core_file = str(core_file) - if executable is not None and lib_search_path is not None: - the_executable = str(executable) - the_lib_search_path = str(lib_search_path) - analyzer = make_shared[NativeCoreFileAnalyzer]( - the_core_file, the_executable, the_lib_search_path - ) - elif executable is not None and lib_search_path is None: - the_executable = str(executable) - analyzer = make_shared[NativeCoreFileAnalyzer](the_core_file, the_executable) - else: - analyzer = make_shared[NativeCoreFileAnalyzer](the_core_file) - return analyzer - - -cdef class CoreFileAnalyzer: - cdef shared_ptr[CoreFileExtractor] _core_analyzer - cdef object ignored_libs - - def __cinit__(self, core_file, executable=None, lib_search_path=None): - self.ignored_libs = frozenset(("ld-linux", "linux-vdso")) - self._initialize_core_analyzer(core_file, executable, lib_search_path) - - @intercept_runtime_errors(EngineError) - def _initialize_core_analyzer(self, core_file, executable, lib_search_path) -> None: - cdef shared_ptr[NativeCoreFileAnalyzer] analyzer = get_core_analyzer( - core_file, executable, lib_search_path - ) - self._core_analyzer = make_shared[CoreFileExtractor](analyzer) - - @intercept_runtime_errors(EngineError) - def extract_maps(self) -> Iterable[Dict[str, Any]]: - mapped_files = self._core_analyzer.get().extractMappedFiles() - memory_maps = self._core_analyzer.get().MemoryMaps() - return generate_maps_from_core_data(mapped_files, memory_maps) - - @intercept_runtime_errors(EngineError) - def extract_pid(self) -> int: - return self._core_analyzer.get().Pid() - - @intercept_runtime_errors(CoreExecutableNotFound) - def extract_executable(self) -> pathlib.Path: - return pathlib.Path(self._core_analyzer.get().extractExecutable()) - - @intercept_runtime_errors(EngineError) - def extract_failure_info(self) -> Dict[str, Any]: - return self._core_analyzer.get().extractFailureInfo() - - @intercept_runtime_errors(EngineError) - def extract_ps_info(self) -> Dict[str, Any]: - return self._core_analyzer.get().extractPSInfo() - - cdef _is_ignored_lib(self, object path): - return any(prefix in str(path) for prefix in self.ignored_libs) - - @intercept_runtime_errors(EngineError) - def missing_modules(self) -> Set[str]: - cdef set result = set() - cdef set missing_mod_names = set() - for mod in self._core_analyzer.get().missingModules(): - path = pathlib.Path(mod) - if not self._is_ignored_lib(path): - result.add(path) - missing_mod_names.add(path.name) - for memmap in self._core_analyzer.get().MemoryMaps(): - path = pathlib.Path(memmap.path) - if path.exists() or self._is_ignored_lib(path): - continue - if path.name not in missing_mod_names: - result.add(path) - return result - - @intercept_runtime_errors(EngineError) - def extract_module_load_points(self) -> Dict[str, int]: - return { - pathlib.Path(mod.filename).name: mod.start - for mod in self._core_analyzer.get().ModuleInformation() - } - - @intercept_runtime_errors(EngineError) - def extract_build_ids(self) -> Tuple[str, str, str]: - cdef object memory_maps = self._core_analyzer.get().MemoryMaps() - cdef object module_information = self._core_analyzer.get().ModuleInformation() - memory_maps_by_file = {map['path']: map['buildid'] for map in memory_maps} - for module in module_information: - filename = module['filename'] - if self._is_ignored_lib(filename): - continue - mod_buildid = module['buildid'] - map_buildid = memory_maps_by_file.get(filename) - yield (filename, mod_buildid, map_buildid) - -cdef class ProcessManager: - cdef shared_ptr[AbstractProcessManager] _manager - - cdef public object pid - cdef public object python_version - cdef public object virtual_maps - cdef public object map_info - - def __init__(self, pid, python_version, memory_maps, map_info): - self.pid = pid - self.python_version = python_version - self.virtual_maps = memory_maps - self.map_info = map_info - - @classmethod - def create_from_pid(cls, int pid, bint stop_process): - cdef shared_ptr[ProcessTracer] tracer - if stop_process: - tracer = make_shared[ProcessTracer](pid) - - virtual_maps = list(generate_maps_for_process(pid)) - map_info = parse_maps_file(pid, virtual_maps) - - cdef shared_ptr[NativeProcessAnalyzer] analyzer = make_shared[ - NativeProcessAnalyzer - ](pid) - cdef shared_ptr[AbstractProcessManager] native_manager = ( - make_shared[NativeProcessManager]( - pid, tracer, analyzer, - _pymaps_to_maps(virtual_maps), - _pymapinfo_to_mapinfo(map_info), - ) - ) - - python_version = native_manager.get().findPythonVersion() - if python_version == (-1, -1): - python_version = get_python_version_for_process(pid, map_info) - native_manager.get().setPythonVersion(python_version) - - cdef ProcessManager new_manager = cls( - pid, python_version, virtual_maps, map_info - ) - new_manager._manager = native_manager - return new_manager - - @classmethod - def create_from_core( - cls, - core_file: pathlib.Path, - executable: pathlib.Path, - lib_search_path: Optional[pathlib.Path], - ): - cdef shared_ptr[NativeCoreFileAnalyzer] analyzer = get_core_analyzer( - core_file, executable, lib_search_path - ) - cdef unique_ptr[CoreFileExtractor] core_extractor = make_unique[ - CoreFileExtractor - ](analyzer) - - mapped_files = core_extractor.get().extractMappedFiles() - memory_maps = core_extractor.get().MemoryMaps() - load_point_by_module = { - pathlib.Path(mod.filename).name: mod.start - for mod in core_extractor.get().ModuleInformation() - } - - virtual_maps = list( - generate_maps_from_core_data(mapped_files, memory_maps) - ) - pid = core_extractor.get().Pid() - map_info = parse_maps_file_for_binary(executable, virtual_maps, load_point_by_module) - - the_core_file = str(core_file) - the_executable = str(executable) - maps = _pymaps_to_maps(virtual_maps) - native_map_info = _pymapinfo_to_mapinfo(map_info) - cdef shared_ptr[AbstractProcessManager] native_manager = ( - make_shared[CoreFileProcessManager](pid, analyzer, maps, native_map_info) - ) - - python_version = native_manager.get().findPythonVersion() - if python_version == (-1, -1): - python_version = get_python_version_for_core(core_file, executable, map_info) - native_manager.get().setPythonVersion(python_version) - cdef ProcessManager new_manager = cls( - pid, python_version, virtual_maps, map_info - ) - new_manager._manager = native_manager - - return new_manager - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self._manager.reset() - - cdef shared_ptr[AbstractProcessManager] get_manager(self): - assert self._manager.get() != NULL - return self._manager - - def interpreter_status(self) -> int: - return self._manager.get().isInterpreterActive() - - def is_interpreter_active(self) -> bool: - return self._manager.get().isInterpreterActive() == InterpreterStatus.RUNNING - - -###################################### -# COMMON STACK-RETRIEVING FUNCTIONS # -###################################### - -cdef object _try_to_decode_string(const cppstring *the_string): - return PyUnicode_Decode(the_string.c_str(), the_string.size(), NULL, "replace") - -cdef object _safe_cppmap_to_py(unordered_map[cppstring, cppstring] themap): - cdef unordered_map[cppstring, cppstring] . iterator it = themap.begin() - cdef dict result = {} - while it != themap.end(): - key = _try_to_decode_string(&(dereference(it).first)) - val = _try_to_decode_string(&(dereference(it).second)) - result[key] = val - postincrement(it) - - return result - -cdef object _construct_frame_stack_from_thread_object( - ssize_t pid, bint resolve_locals, FrameObject *first_frame -): - cdef CodeObject *current_code = NULL - cdef FrameObject *current_frame = first_frame - - last_frame = None - - while current_frame != NULL: - current_code = current_frame.Code().get() - - filename = current_code.Filename() - location_info = LocationInfo( - current_code.Location().lineno, - current_code.Location().end_lineno, - current_code.Location().column, - current_code.Location().end_column, - ) - py_code = PyCodeObject(filename, current_code.Scope(), location_info) - - if resolve_locals: - current_frame.resolveLocalVariables() - - args = _safe_cppmap_to_py(current_frame.Arguments()) - locals = _safe_cppmap_to_py(current_frame.Locals()) - is_entry = current_frame.IsEntryFrame() - py_frame = PyFrame(None, None, py_code, args, locals, is_entry) - - py_frame.next = last_frame - if last_frame: - last_frame.prev = py_frame - - last_frame = py_frame - current_frame = ( - current_frame.PreviousFrame().get() - if current_frame.PreviousFrame() - else NULL - ) - - return last_frame - -cdef object _construct_threads_from_interpreter_state( - shared_ptr[AbstractProcessManager] manager, - remote_addr_t head, - int pid, - object python_version, - bint add_native_traces, - bint resolve_locals, -): - LOGGER.info("Fetching Python threads") - threads = [] - - cdef shared_ptr[Thread] thread = getThreadFromInterpreterState(manager, head) - cdef Thread *current_thread = thread.get() - while current_thread != NULL: - LOGGER.info("Constructing new Python thread with tid %s", current_thread.Tid()) - if add_native_traces: - current_thread.populateNativeStackTrace(manager) - frame = _construct_frame_stack_from_thread_object( - pid, resolve_locals, current_thread.FirstFrame().get() - ) - native_frames = [ - NativeFrame(**native_frame) - for native_frame in list(current_thread.NativeFrames()) - ] - threads.append( - PyThread( - current_thread.Tid(), - frame, - native_frames[::-1], - current_thread.isGilHolder(), - current_thread.isGCCollecting(), - python_version, - name=get_thread_name(pid, current_thread.Tid()), - ) - ) - current_thread = ( - current_thread.NextThread().get() if current_thread.NextThread() else NULL - ) - - return threads - -cdef object _construct_os_thread( - shared_ptr[AbstractProcessManager] manager, int pid, int tid -): - cdef unique_ptr[NativeThread] thread = make_unique[NativeThread](pid, tid) - thread.get().populateNativeStackTrace(manager) - native_frames = [ - NativeFrame(**native_frame) - for native_frame in list(thread.get().NativeFrames()) - ] - LOGGER.info("Constructing new native thread with tid %s", tid) - pythread = PyThread( - tid, - None, - native_frames[::-1], - False, - False, - None, - name=get_thread_name(pid, tid), - ) - - return pythread - -cdef object _construct_os_threads( - shared_ptr[AbstractProcessManager] manager, int pid, object tids -): - LOGGER.info("Fetching native threads") - threads = [] - for tid in tids: - threads.append(_construct_os_thread(manager, pid, tid)) - - return threads - -cdef remote_addr_t _get_interpreter_state_addr( - AbstractProcessManager *manager, object method, int core=False -) except*: - cdef remote_addr_t head = 0 - possible_methods = [ - StackMethod.ELF_DATA, - StackMethod.SYMBOLS, - StackMethod.BSS, - StackMethod.ANONYMOUS_MAPS, - StackMethod.HEAP, - ] - - for possible_method in possible_methods: - if method.value & possible_method.value == 0: - continue - - try: - if possible_method == StackMethod.ELF_DATA: - how = "using ELF data" - head = manager.findInterpreterStateFromElfData() - elif possible_method == StackMethod.SYMBOLS: - how = "using symbols" - head = manager.findInterpreterStateFromSymbols() - elif possible_method == StackMethod.BSS: - how = "scanning the BSS" - head = manager.scanBSS() - elif possible_method == StackMethod.ANONYMOUS_MAPS: - how = "scanning all anonymous maps" - head = manager.scanAllAnonymousMaps() - elif possible_method == StackMethod.HEAP: - how = "scanning the heap" - head = manager.scanHeap() - except Exception as exc: - LOGGER.warning( - "Unexpected error finding PyInterpreterState by %s: %s", how, exc - ) - - if head: - LOGGER.info("PyInterpreterState found by %s at address 0x%0.2X", how, head) - return head - else: - LOGGER.info("Address of PyInterpreterState not found by %s", how) - - LOGGER.info("Address of PyInterpreterState could not be found") - return 0 - - -def _check_interpreter_shutdown(manager): - status = manager.interpreter_status() - if status == InterpreterStatus.UNKNOWN: - return - if status == InterpreterStatus.FINALIZED: - msg = ( - "The interpreter is shutting itself down so it is possible that no Python" - " stack trace is available for inspection. You can still use --native-all " - " to force displaying all the threads." - ) - LOGGER.warning(msg) - else: - LOGGER.info("An active interpreter has been detected") - - -##################### -# PROCESS FUNCTIONS # -##################### - - -def _get_process_threads( - pymanager: ProcessManager, - pid: int, - native_mode: NativeReportingMode, - resolve_locals: bool, - method: StackMethod, -): - LOGGER.debug("Available memory maps for process:") - for mem_map in pymanager.virtual_maps: - LOGGER.debug(mem_map) - - cdef shared_ptr[AbstractProcessManager] manager = pymanager.get_manager() - - if native_mode != NativeReportingMode.ALL: - _check_interpreter_shutdown(pymanager) - - cdef remote_addr_t head = _get_interpreter_state_addr(manager.get(), method) - - if not head and native_mode != NativeReportingMode.ALL: - raise NotEnoughInformation( - "Could not gather enough information to extract the Python frame information" - ) - - all_tids = list(manager.get().Tids()) - if head: - add_native_traces = native_mode != NativeReportingMode.OFF - for thread in _construct_threads_from_interpreter_state( - manager, - head, - pid, - pymanager.python_version, - add_native_traces, - resolve_locals, - ): - if thread.tid in all_tids: - all_tids.remove(thread.tid) - yield thread - - if native_mode == NativeReportingMode.ALL: - yield from _construct_os_threads(manager, pid, all_tids) - - -def get_process_threads( - pid: int, - stop_process: bool = True, - native_mode: NativeReportingMode = NativeReportingMode.OFF, - locals: bool = False, - method: StackMethod = StackMethod.AUTO, -) -> Iterable[PyThread]: - """Return an iterable of Thread objects that are registered with the remote interpreter - - Args: - pid (int): The pid of the remote process - stop_process (bool): If *True*, stop the process for analysis and use - blocking APis to obtain remote information. - native_mode (NativeReportingMode): If set to PYTHON, include the - native (C/C++) stack in the returned Thread objects for all threads - registered with the interpreter. If set to ALL, native stacks - from threads not registered with the interpreter will be provided - as well. By default this is set to OFF and native stacks are not - returned. - locals (bool): If **True**, retrieve the local variables and arguments for - every retrieved frame (may slow down the processing). - method (StackMethod): The method to locate the relevant Python structs - that are needed to unwind the Python stack. - - Returns: - Iterable of Thread objects. - """ - if not isinstance(method, StackMethod): - raise ValueError("Invalid method for stack analysis") - - LOGGER.info( - "Analyzing process with pid %s using stack method %s with native mode %s", - pid, - method, - native_mode, - ) - - try: - with ProcessManager.create_from_pid(pid, stop_process) as manager: - yield from _get_process_threads(manager, pid, native_mode, locals, method) - except RuntimeError as e: - raise EngineError(*e.args, pid=pid) from e - - -###################### -# COREFILE FUNCTIONS # -###################### - - -def get_process_threads_for_core( - core_file: pathlib.Path, - executable: pathlib.Path, - library_search_path: str = None, - native_mode: NativeReportingMode = NativeReportingMode.PYTHON, - locals: bool = False, - method: StackMethod = StackMethod.AUTO, -) -> Iterable[PyThread]: - """Return an iterable of Thread objects that are registered with the given core file - - Args: - core_file (pathlib.Path): The location of the core file to analyze. - executable (pathlib.Path): The location of the executable that the core file - was created from. - library_search_path (str): A ":"-separated list of directories to use when - trying to locate missing shared libraries in the core file. - native_mode (NativeReportingMode): If set to PYTHON, include the - native (C/C++) stack in the returned Thread objects for all threads - registered with the interpreter. If set to ALL, native stacks - from threads not registered with the interpreter will be provided - as well. By default this is set to OFF and native stacks are not - returned. - locals (bool): If **True**, retrieve the local variables and arguments for - every retrieved frame (may slow down the processing). - method (StackMethod): The method to locate the relevant Python structs - that are needed to unwind the Python stack. - - Returns: - Iterable of Thread objects. - """ - if not isinstance(method, StackMethod): - raise ValueError("Invalid method for stack analysis") - - LOGGER.info( - "Analyzing core file %s with executable %s using stack method %s with native mode %s", - core_file, - executable, - method, - native_mode, - ) - try: - yield from _get_process_threads_for_core( - core_file, executable, library_search_path, native_mode, locals, method - ) - except RuntimeError as e: - raise EngineError(*e.args, corefile=core_file) from e - - -def _get_process_threads_for_core( - corefile: pathlib.Path, - executable: pathlib.Path, - library_search_path: str = None, - native_mode: NativeReportingMode = NativeReportingMode.PYTHON, - locals: bool = False, - method: StackMethod = StackMethod.AUTO, -) -> Iterable[PyThread]: - cdef ProcessManager pymanager = ProcessManager.create_from_core( - corefile, executable, library_search_path - ) - - LOGGER.debug("Available memory maps for core:") - for mem_map in pymanager.virtual_maps: - LOGGER.debug(mem_map) - - cdef shared_ptr[AbstractProcessManager] manager = pymanager.get_manager() - - if native_mode != NativeReportingMode.ALL: - _check_interpreter_shutdown(pymanager) - - cdef remote_addr_t head = _get_interpreter_state_addr( - manager.get(), method, core=True - ) - - if not head and native_mode != NativeReportingMode.ALL: - raise NotEnoughInformation( - "Could not gather enough information to extract the Python frame information" - ) - - all_tids = list(manager.get().Tids()) - - if head: - native = native_mode in {NativeReportingMode.PYTHON, NativeReportingMode.ALL} - for thread in _construct_threads_from_interpreter_state( - manager, head, pymanager.pid, pymanager.python_version, native, locals - ): - if thread.tid in all_tids: - all_tids.remove(thread.tid) - yield thread - - if native_mode == NativeReportingMode.ALL: - yield from _construct_os_threads(manager, pymanager.pid, all_tids) diff --git a/src/pystack/_pystack/CMakeLists.txt b/src/pystack/_pystack/CMakeLists.txt index 5a0fd8a8..90c8f766 100644 --- a/src/pystack/_pystack/CMakeLists.txt +++ b/src/pystack/_pystack/CMakeLists.txt @@ -1,26 +1,56 @@ -cmake_minimum_required(VERSION 2.8) -project(_pystack) -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) +# PyStack C++ extension module via nanobind -find_package(PythonInterp 3.7 REQUIRED) -find_package(PythonLibs 3.7 REQUIRED) -IF(NOT PYTHONLIBS_FOUND OR NOT PYTHON_EXECUTABLE) - MESSAGE(SEND_ERROR "You need Python to build Python binding") -ENDIF(NOT PYTHONLIBS_FOUND OR NOT PYTHON_EXECUTABLE) +# Find pthreads +set(THREADS_PREFER_PTHREAD_FLAG ON) +find_package(Threads REQUIRED) -add_library(_pystack STATIC - corefile.cpp - unwinder.cpp - logging.cpp - mem.cpp - process.cpp - pycode.cpp - pyframe.cpp - pythread.cpp - version.cpp - elf_common.cpp - pytypes.cpp) -set_property(TARGET _pystack PROPERTY POSITION_INDEPENDENT_CODE ON) -include_directories("." "cpython" ${PYTHON_INCLUDE_DIRS}) +# Collect all C++ source files +set(PYSTACK_SOURCES + corefile.cpp + elf_common.cpp + logging.cpp + maps_parser.cpp + mem.cpp + native_frame.cpp + process.cpp + pycode.cpp + pyframe.cpp + pythread.cpp + pytypes.cpp + thread_builder.cpp + unwinder.cpp + version.cpp + version_detector.cpp + bindings.cpp + interpreter.cpp +) + +# Create the nanobind module +nanobind_add_module( + _pystack + STABLE_ABI + NB_STATIC + ${PYSTACK_SOURCES} +) + +# Include directories +# Note: We only include the source directory, not cpython/ directly. +# The cpython headers are included with "cpython/..." prefix to avoid +# conflicts with system headers (e.g., cpython/pthread.h vs /usr/include/pthread.h) +target_include_directories(_pystack PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} +) + +# Link libraries +target_link_libraries(_pystack PRIVATE + Threads::Threads + stdc++fs + LIBDW + LIBELF +) + +# Add pthread compile options (needed for C++ threading support on Linux) +target_compile_options(_pystack PRIVATE -pthread) + +# Install the module (destination is relative to wheel.install-dir in pyproject.toml) +install(TARGETS _pystack LIBRARY DESTINATION .) diff --git a/src/pystack/_pystack/__init__.pxd b/src/pystack/_pystack/__init__.pxd deleted file mode 100644 index e69de29b..00000000 diff --git a/src/pystack/_pystack/bindings.cpp b/src/pystack/_pystack/bindings.cpp new file mode 100644 index 00000000..98a64879 --- /dev/null +++ b/src/pystack/_pystack/bindings.cpp @@ -0,0 +1,1120 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "corefile.h" +#include "elf_common.h" +#include "interpreter.h" +#include "logging.h" +#include "maps_parser.h" +#include "mem.h" +#include "native_frame.h" +#include "process.h" +#include "thread_builder.h" + +namespace nb = nanobind; +using namespace nb::literals; +// Note: We don't use "using namespace pystack;" because it conflicts with Python's PyObject + +// Simple exception classes that store the message +class NotEnoughInformationError : public std::exception +{ + public: + explicit NotEnoughInformationError(const std::string& message) + : d_message(message) + { + } + const char* what() const noexcept override + { + return d_message.c_str(); + } + + private: + std::string d_message; +}; + +class EngineError : public std::exception +{ + public: + explicit EngineError(const std::string& message) + : d_message(message) + { + } + const char* what() const noexcept override + { + return d_message.c_str(); + } + + private: + std::string d_message; +}; + +[[noreturn]] void +raise_not_enough_information(const char* message) +{ + throw NotEnoughInformationError(message); +} + +[[noreturn]] void +raise_python_exception( + const char* exception_name, + const std::string& message, + std::optional pid = std::nullopt, + std::optional corefile = std::nullopt) +{ + nb::object exc_type = nb::module_::import_("pystack.errors").attr(exception_name); + nb::dict kwargs; + if (pid) { + kwargs["pid"] = *pid; + } + if (corefile) { + kwargs["corefile"] = *corefile; + } + + nb::tuple args = nb::make_tuple(message); + PyObject* exc = PyObject_Call(exc_type.ptr(), args.ptr(), kwargs.ptr()); + if (!exc) { + throw nb::python_error(); + } + + PyErr_SetObject(exc_type.ptr(), exc); + Py_DECREF(exc); + throw nb::python_error(); +} + +// StackMethod enum values (must match Python enum) +enum class StackMethod { + ELF_DATA = 1 << 0, + SYMBOLS = 1 << 1, + BSS = 1 << 2, + ANONYMOUS_MAPS = 1 << 3, + HEAP = 1 << 4, + DEBUG_OFFSETS = 1 << 5, + AUTO = (1 << 5) | (1 << 0) | (1 << 1) | (1 << 2), + ALL = (1 << 5) | (1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4), +}; + +enum class NativeReportingMode { + OFF = 0, + PYTHON = 1, + ALL = 1000, + LAST = 2000, +}; + +class CoreFileAnalyzerWrapper +{ + public: + CoreFileAnalyzerWrapper( + const std::filesystem::path& corefile, + std::optional executable = std::nullopt, + std::optional lib_search_path = std::nullopt) + : d_ignored_libs({"ld-linux", "linux-vdso"}) + { + std::string corefile_str = corefile.string(); + if (executable && lib_search_path) { + d_analyzer = std::make_shared( + corefile_str, + executable->string(), + lib_search_path->string()); + } else if (executable) { + d_analyzer = std::make_shared(corefile_str, executable->string()); + } else { + d_analyzer = std::make_shared(corefile_str); + } + d_extractor = std::make_unique(d_analyzer); + } + + nb::list extract_maps() const + { + auto mapped_files = d_extractor->extractMappedFiles(); + auto memory_maps = d_extractor->MemoryMaps(); + auto maps = parseCoreFileMaps(mapped_files, memory_maps); + + nb::module_ pystack_maps = nb::module_::import_("pystack.maps"); + nb::object VirtualMap = pystack_maps.attr("VirtualMap"); + + nb::list result; + for (const auto& map : maps) { + std::string path_str = map.Path(); + nb::object path_obj = + path_str.empty() ? nb::none() : nb::cast(std::filesystem::path(path_str)); + nb::object vm = VirtualMap( + map.Start(), + map.End(), + map.FileSize(), + map.Offset(), + map.Device(), + map.Flags(), + map.Inode(), + path_obj); + result.append(vm); + } + return result; + } + + int extract_pid() const + { + return d_extractor->Pid(); + } + + std::filesystem::path extract_executable() const + { + return std::filesystem::path(d_extractor->extractExecutable()); + } + + nb::dict extract_failure_info() const + { + auto info = d_extractor->extractFailureInfo(); + nb::dict result; + result["si_signo"] = info.si_signo; + result["si_errno"] = info.si_errno; + result["si_code"] = info.si_code; + result["sender_pid"] = info.sender_pid; + result["sender_uid"] = info.sender_uid; + result["failed_addr"] = info.failed_addr; + return result; + } + + nb::dict extract_ps_info() const + { + auto info = d_extractor->extractPSInfo(); + nb::dict result; + result["state"] = static_cast(info.state); + result["sname"] = static_cast(info.sname); + result["zomb"] = static_cast(info.zomb); + result["nice"] = static_cast(info.nice); + result["flag"] = info.flag; + result["uid"] = info.uid; + result["gid"] = info.gid; + result["pid"] = info.pid; + result["ppid"] = info.ppid; + result["pgrp"] = info.pgrp; + result["sid"] = info.sid; + result["fname"] = std::string(info.fname); + result["psargs"] = std::string(info.psargs); + return result; + } + + nb::set missing_modules() const + { + std::unordered_set result_paths; + std::unordered_set missing_module_names; + for (const auto& mod : d_extractor->missingModules()) { + if (!isIgnoredLib(mod)) { + result_paths.insert(mod); + missing_module_names.insert(std::filesystem::path(mod).filename().string()); + } + } + for (const auto& memmap : d_extractor->MemoryMaps()) { + std::string path = memmap.path; + if (path.empty() || isIgnoredLib(path)) { + continue; + } + // Check if path exists + std::ifstream f(path); + if (f.good()) { + continue; + } + // Check if already in result + auto fname = std::filesystem::path(path).filename().string(); + if (missing_module_names.find(fname) == missing_module_names.end()) { + result_paths.insert(path); + } + } + + nb::set result; + for (const auto& path : result_paths) { + result.add(nb::cast(std::filesystem::path(path))); + } + return result; + } + + nb::dict extract_module_load_points() const + { + nb::dict result; + for (const auto& mod : d_extractor->ModuleInformation()) { + auto name = std::filesystem::path(mod.filename).filename().string(); + result[nb::cast(name)] = mod.start; + } + return result; + } + + nb::list extract_build_ids() const + { + nb::list result; + auto memory_maps = d_extractor->MemoryMaps(); + auto module_info = d_extractor->ModuleInformation(); + + std::unordered_map maps_by_file; + for (const auto& map : memory_maps) { + maps_by_file[map.path] = map.buildid; + } + + for (const auto& mod : module_info) { + if (isIgnoredLib(mod.filename)) { + continue; + } + auto map_buildid_it = maps_by_file.find(mod.filename); + std::string map_buildid = + (map_buildid_it != maps_by_file.end()) ? map_buildid_it->second : ""; + result.append(nb::make_tuple(mod.filename, mod.buildid, map_buildid)); + } + return result; + } + + private: + bool isIgnoredLib(const std::string& path) const + { + for (const auto& prefix : d_ignored_libs) { + if (path.find(prefix) != std::string::npos) { + return true; + } + } + return false; + } + + std::shared_ptr d_analyzer; + std::unique_ptr d_extractor; + std::vector d_ignored_libs; +}; + +class ProcessManagerWrapper +{ + public: + explicit ProcessManagerWrapper(std::shared_ptr manager) + : d_manager(std::move(manager)) + { + } + + static std::unique_ptr create_from_pid(pid_t pid, bool stop_process) + { + auto manager = pystack::ProcessManager::create(pid, stop_process); + return std::make_unique(std::move(manager)); + } + + static std::unique_ptr create_from_core( + const std::filesystem::path& core_file, + const std::filesystem::path& executable, + std::optional lib_search_path) + { + std::optional lib_path_str; + if (lib_search_path) { + lib_path_str = lib_search_path->string(); + } + auto manager = pystack::CoreFileProcessManager::create( + core_file.string(), + executable.string(), + lib_path_str); + return std::make_unique(std::move(manager)); + } + + int interpreter_status() const + { + return static_cast(d_manager->isInterpreterActive()); + } + + bool is_interpreter_active() const + { + return d_manager->isInterpreterActive() + == pystack::AbstractProcessManager::InterpreterStatus::RUNNING; + } + + void reset() + { + d_manager.reset(); + } + + pid_t pid() const + { + return d_manager->Pid(); + } + + std::pair python_version() const + { + return d_manager->Version(); + } + + const std::vector& virtual_maps() const + { + return d_manager->MemoryMaps(); + } + + std::shared_ptr get_manager() const + { + return d_manager; + } + + private: + std::shared_ptr d_manager; +}; + +nb::object +get_bss_info(const std::filesystem::path& binary) +{ + pystack::SectionInfo info; + if (pystack::getSectionInfo(binary.string(), ".bss", &info)) { + nb::dict result; + result["name"] = info.name; + result["flags"] = info.flags; + result["addr"] = info.addr; + result["corrected_addr"] = info.corrected_addr; + result["offset"] = info.offset; + result["size"] = info.size; + return result; + } + return nb::none(); +} + +// Helper struct to hold Python type objects for thread building +struct PyTypes +{ + nb::object PyThread; + nb::object PyFrame; + nb::object PyCodeObject; + nb::object LocationInfo; + nb::object NativeFrame; + + static PyTypes load() + { + nb::module_ pystack_types = nb::module_::import_("pystack.types"); + return {pystack_types.attr("PyThread"), + pystack_types.attr("PyFrame"), + pystack_types.attr("PyCodeObject"), + pystack_types.attr("LocationInfo"), + pystack_types.attr("NativeFrame")}; + } +}; + +// Build frame chain from C++ thread data +nb::object +buildFrameChain(const pystack::PyThreadData& thread, const PyTypes& types) +{ + nb::object first_frame = nb::none(); + nb::object prev_frame = nb::none(); + + // Frames from C++ are in innermost-to-outermost order + // Python iterates via .next and expects: -> first_func -> second_func -> third_func + // So we iterate in reverse to build the list in the correct order + for (auto it = thread.frames.rbegin(); it != thread.frames.rend(); ++it) { + const auto& frame_data = *it; + nb::object location = types.LocationInfo( + frame_data.code.location.lineno, + frame_data.code.location.end_lineno, + frame_data.code.location.column, + frame_data.code.location.end_column); + nb::object code = types.PyCodeObject(frame_data.code.filename, frame_data.code.scope, location); + + nb::dict args; + for (const auto& [k, v] : frame_data.arguments) { + args[nb::cast(k)] = v; + } + nb::dict locs; + for (const auto& [k, v] : frame_data.locals) { + locs[nb::cast(k)] = v; + } + + nb::object py_frame = types.PyFrame( + prev_frame, + nb::none(), + code, + args, + locs, + frame_data.is_entry, + frame_data.is_shim); + + if (!prev_frame.is_none()) { + prev_frame.attr("next") = py_frame; + } + + if (first_frame.is_none()) { + first_frame = py_frame; + } + prev_frame = py_frame; + } + + return first_frame; +} + +// Build native frames list +nb::list +buildNativeFramesList(const std::vector& native_frames, const PyTypes& types) +{ + nb::list result; + for (const auto& nf : native_frames) { + result.append(types.NativeFrame( + nf.address, + nf.symbol, + nf.path, + nf.linenumber, + nf.colnumber, + nf.library)); + } + return result; +} + +// Build a Python thread object from C++ thread data +nb::object +buildPyThreadObject( + const pystack::PyThreadData& thread, + const PyTypes& types, + std::pair python_version) +{ + nb::object first_frame = buildFrameChain(thread, types); + nb::list native_frames = buildNativeFramesList(thread.native_frames, types); + + return types.PyThread( + thread.tid, + first_frame, + native_frames, + thread.gil_status, + thread.gc_status, + nb::make_tuple(python_version.first, python_version.second), + "name"_a = thread.name ? nb::cast(*thread.name) : nb::none(), + "interpreter_id"_a = thread.interpreter_id); +} + +// Build a native-only thread object (no Python frames) +nb::object +buildNativeOnlyThreadObject(const pystack::PyThreadData& thread, const PyTypes& types) +{ + nb::list native_frames = buildNativeFramesList(thread.native_frames, types); + + return types.PyThread( + thread.tid, + nb::none(), + native_frames, + 0, + 0, + nb::none(), + "name"_a = thread.name ? nb::cast(*thread.name) : nb::none()); +} + +// Log interpreter status +void +logInterpreterStatus(int status) +{ + if (status == static_cast(pystack::AbstractProcessManager::InterpreterStatus::FINALIZED)) { + pystack::LOG(pystack::WARNING) + << "The interpreter is shutting itself down so it is possible that no " + "Python stack trace is available for inspection."; + } else if (status == static_cast(pystack::AbstractProcessManager::InterpreterStatus::RUNNING)) { + pystack::LOG(pystack::INFO) << "An active interpreter has been detected"; + } +} + +// Log available memory maps +void +logMemoryMaps(const std::vector& maps, const char* source) +{ + pystack::LOG(pystack::DEBUG) << "Available memory maps for " << source << ":"; + for (const auto& map : maps) { + pystack::LOG(pystack::DEBUG) + << " " << std::hex << map.Start() << "-" << map.End() << " " << map.Path(); + } +} + +size_t +numEntryFrames(const pystack::PyThreadData& thread) +{ + return std::count_if( + thread.frames.begin(), + thread.frames.end(), + [](const pystack::PyFrameData& frame) { return frame.is_entry; }); +} + +std::vector +sortThreadsByStackAnchor(std::vector data) +{ + // Sort by: + // 1. With stack anchor (!=0) before without + // 2. Stack anchor in descending order + // 3. Index in the vector as a tie breaker (handled by stable_sort) + std::stable_sort( + data.begin(), + data.end(), + [](const pystack::PyThreadData& a, const pystack::PyThreadData& b) { + return std::make_tuple(a.stack_anchor == 0 ? 1 : 0, -a.stack_anchor) + < std::make_tuple(b.stack_anchor == 0 ? 1 : 0, -b.stack_anchor); + }); + return data; +} + +std::vector +sliceNativeStack(std::vector data, std::pair python_version) +{ + if (data.empty() || data[0].native_frames.empty()) { + return data; + } + + const std::vector native_frames = data[0].native_frames; + + std::vector eval_index; + for (std::size_t i = 0; i < native_frames.size(); ++i) { + if (pystack::is_eval_frame(native_frames[i].symbol, python_version)) { + eval_index.push_back(i); + } + } + + const auto total_entry_frames = std::accumulate( + data.begin(), + data.end(), + size_t{0}, + [](size_t acc, const pystack::PyThreadData& d) { return acc + numEntryFrames(d); }); + + if (eval_index.size() != total_entry_frames) { + pystack::LOG(pystack::DEBUG) << "Skipping same-tid native slicing for tid " << data[0].tid + << " due to mismatched counts: entry=" << total_entry_frames + << ", eval=" << eval_index.size(); + return data; + } + + // Slice frames according to eval frames per python thread + std::size_t cursor = 0; + auto native_frames_cursor = native_frames.begin(); + for (auto& thread_data : data) { + const auto num_entry_frames_for_thread = numEntryFrames(thread_data); + + if (num_entry_frames_for_thread == 0) { + thread_data.native_frames.clear(); + continue; + } + + const std::size_t next_thread_cursor = cursor + num_entry_frames_for_thread; + const std::size_t next_thread_first_eval = next_thread_cursor < eval_index.size() + ? eval_index[next_thread_cursor] + : native_frames.size(); + const auto next_thread_native_frames_cursor = native_frames.begin() + next_thread_first_eval; + + thread_data.native_frames.assign(native_frames_cursor, next_thread_native_frames_cursor); + native_frames_cursor = next_thread_native_frames_cursor; + cursor = next_thread_cursor; + } + return data; +} + +std::vector +normalizeThreads( + std::vector threads, + NativeReportingMode native_mode, + std::pair python_version) +{ + if (pystack::isLoggingEnabled(pystack::DEBUG)) { + for (const auto& thread : threads) { + // clang-format off: manually formatted with one field per line + pystack::LOG(pystack::DEBUG) + << "Discovered Python thread" + << " tid=" << thread.tid + << " interpreter_id=" << thread.interpreter_id + << " stack_anchor=" << std::hex << std::showbase << thread.stack_anchor + << " num_frames=" << std::dec << thread.frames.size() + << " num_native_frames=" << thread.native_frames.size(); + for (const auto& frame : thread.frames) { + pystack::LOG(pystack::DEBUG) + << " Frame: " << frame.code.scope + << " file=" << frame.code.filename + << " line=" << frame.code.location.lineno + << " end_line=" << frame.code.location.end_lineno + << " column=" << frame.code.location.column + << " end_column=" << frame.code.location.end_column + << " is_entry=" << frame.is_entry + << " is_shim=" << frame.is_shim + << " num_args=" << frame.arguments.size() + << " num_locals=" << frame.locals.size(); + } + // clang-format on + } + } + + // Group threads by TID, preserving first-seen order. + // One TID can have multiple PyThreadData due to subinterpreters. + std::unordered_map tid_to_group; + std::vector> groups; + + for (auto& thread : threads) { + auto [it, inserted] = tid_to_group.try_emplace(thread.tid, groups.size()); + if (inserted) { + groups.emplace_back(); + } + groups[it->second].push_back(std::move(thread)); + } + + std::vector ret; + for (auto& group : groups) { + if (group.size() > 1) { + // Order interpreters for this TID from outermost to innermost + group = sortThreadsByStackAnchor(std::move(group)); + // Associate each Python stack with its chunk of the native stack + if (native_mode != NativeReportingMode::OFF) { + group = sliceNativeStack(std::move(group), python_version); + } + } + for (auto& thread : group) { + ret.push_back(std::move(thread)); + } + } + + return ret; +} + +nb::object +get_process_threads( + pid_t pid, + bool stop_process, + NativeReportingMode native_mode, + bool locals, + StackMethod method) +{ + auto types = PyTypes::load(); + + try { + // Collect all C++ data with GIL released so other threads can run + // (e.g. concurrent ptrace attachment attempts will see EPERM). + std::vector python_threads; + std::vector native_only_threads; + std::pair python_version; + bool not_enough_info = false; + + { + nb::gil_scoped_release release; + + auto manager = ProcessManagerWrapper::create_from_pid(pid, stop_process); + logMemoryMaps(manager->virtual_maps(), "process"); + + if (native_mode != NativeReportingMode::ALL) { + logInterpreterStatus(manager->interpreter_status()); + } + + pystack::remote_addr_t head = pystack::getInterpreterStateAddr( + manager->get_manager().get(), + static_cast(method)); + + if (head == 0 && native_mode != NativeReportingMode::ALL) { + not_enough_info = true; + } else { + python_version = manager->python_version(); + std::vector all_tids = pystack::getThreadIds(manager->get_manager()); + bool add_native = native_mode != NativeReportingMode::OFF; + + while (head) { + auto next = + pystack::InterpreterUtils::getNextInterpreter(manager->get_manager(), head); + if (next && !manager->get_manager()->versionIsAtLeast(3, 11)) { + // We are currently unable to reliably determine the + // order of the stacks for different interpreters on + // the same OS thread for versions < 3.11, and so we + // ignore interpreters but the main one (which should + // be the last one in the linked list). + pystack::LOG(pystack::WARNING) + << "Ignoring subinterpreter at address " << std::hex << std::showbase + << head << ". pystack can only handle subinterpreters for Python 3.11+"; + head = next; + continue; + } + + std::vector new_threads = + pystack::buildThreadsFromInterpreter( + manager->get_manager(), + head, + pid, + add_native, + locals); + + for (const auto& thread : new_threads) { + all_tids.erase( + std::remove(all_tids.begin(), all_tids.end(), thread.tid), + all_tids.end()); + } + python_threads.insert( + python_threads.end(), + std::make_move_iterator(new_threads.begin()), + std::make_move_iterator(new_threads.end())); + head = next; + } + + if (native_mode == NativeReportingMode::ALL) { + for (int tid : all_tids) { + native_only_threads.push_back( + pystack::buildNativeThread(manager->get_manager(), pid, tid)); + } + } + + manager->reset(); + } + } + + // GIL re-acquired: build Python objects + if (not_enough_info) { + raise_not_enough_information( + "Could not gather enough information to extract the Python frame information"); + } + + nb::list result; + for (const auto& thread : normalizeThreads(python_threads, native_mode, python_version)) { + result.append(buildPyThreadObject(thread, types, python_version)); + } + for (const auto& thread : native_only_threads) { + result.append(buildNativeOnlyThreadObject(thread, types)); + } + return result; + } catch (const NotEnoughInformationError&) { + throw; + } catch (const EngineError&) { + throw; + } catch (const std::exception& e) { + throw EngineError(e.what()); + } +} + +nb::object +get_process_threads_for_core( + const std::filesystem::path& core_file, + const std::filesystem::path& executable, + std::optional library_search_path, + NativeReportingMode native_mode, + bool locals, + StackMethod method) +{ + auto types = PyTypes::load(); + + try { + auto manager = + ProcessManagerWrapper::create_from_core(core_file, executable, library_search_path); + logMemoryMaps(manager->virtual_maps(), "core"); + + if (native_mode != NativeReportingMode::ALL) { + logInterpreterStatus(manager->interpreter_status()); + } + + pystack::remote_addr_t head = + pystack::getInterpreterStateAddr(manager->get_manager().get(), static_cast(method)); + + if (head == 0 && native_mode != NativeReportingMode::ALL) { + raise_not_enough_information( + "Could not gather enough information to extract the Python frame information"); + } + + nb::list result; + std::vector python_threads; + std::vector all_tids = pystack::getThreadIds(manager->get_manager()); + bool add_native = native_mode != NativeReportingMode::OFF; + + while (head) { + auto next = pystack::InterpreterUtils::getNextInterpreter(manager->get_manager(), head); + if (next && !manager->get_manager()->versionIsAtLeast(3, 11)) { + // We are currently unable to reliably determine the + // order of the stacks for different interpreters on + // the same OS thread for versions < 3.11, and so we + // ignore interpreters but the main one (which should + // be the last one in the linked list). + pystack::LOG(pystack::WARNING) + << "Ignoring subinterpreter at address " << std::hex << std::showbase << head + << ". pystack can only handle subinterpreters for Python 3.11+"; + head = next; + continue; + } + + auto new_threads = pystack::buildThreadsFromInterpreter( + manager->get_manager(), + head, + manager->pid(), + add_native, + locals); + + for (const auto& thread : new_threads) { + all_tids.erase( + std::remove(all_tids.begin(), all_tids.end(), thread.tid), + all_tids.end()); + } + python_threads.insert( + python_threads.end(), + std::make_move_iterator(new_threads.begin()), + std::make_move_iterator(new_threads.end())); + + head = next; + } + + for (const auto& thread : + normalizeThreads(python_threads, native_mode, manager->python_version())) + { + result.append(buildPyThreadObject(thread, types, manager->python_version())); + } + + if (native_mode == NativeReportingMode::ALL) { + for (int tid : all_tids) { + auto thread = pystack::buildNativeThread(manager->get_manager(), manager->pid(), tid); + result.append(buildNativeOnlyThreadObject(thread, types)); + } + } + + return result; + } catch (const NotEnoughInformationError&) { + throw; + } catch (const EngineError&) { + throw; + } catch (const std::exception& e) { + throw EngineError(e.what()); + } +} + +void +_check_interpreter_shutdown(nb::object manager) +{ + int status = nb::cast(manager.attr("interpreter_status")()); + + if (status == static_cast(pystack::AbstractProcessManager::InterpreterStatus::FINALIZED)) { + pystack::LOG(pystack::WARNING) + << "The interpreter is shutting itself down so it is possible that no " + "Python stack trace is available for inspection."; + } else if (status != -1) { + // -1 means failed to detect, 2 means FINALIZED (already handled above) + // Other values mean running/active + pystack::LOG(pystack::INFO) << "An active interpreter has been detected"; + } +} + +NB_MODULE(_pystack, m) +{ + m.doc() = "PyStack native extension module"; + + nb::register_exception_translator([](const std::exception_ptr& p, void*) { + try { + if (p) std::rethrow_exception(p); + } catch (const NotEnoughInformationError& e) { + nb::object exc_type = nb::module_::import_("pystack.errors").attr("NotEnoughInformation"); + PyErr_SetString(exc_type.ptr(), e.what()); + } catch (const EngineError& e) { + nb::object exc_type = nb::module_::import_("pystack.errors").attr("EngineError"); + PyErr_SetString(exc_type.ptr(), e.what()); + } + }); + + pystack::initializePythonLoggerInterface(); + + nb::enum_(m, "StackMethod", nb::is_flag()) + .value("ELF_DATA", StackMethod::ELF_DATA) + .value("SYMBOLS", StackMethod::SYMBOLS) + .value("BSS", StackMethod::BSS) + .value("ANONYMOUS_MAPS", StackMethod::ANONYMOUS_MAPS) + .value("HEAP", StackMethod::HEAP) + .value("DEBUG_OFFSETS", StackMethod::DEBUG_OFFSETS) + .value("AUTO", StackMethod::AUTO) + .value("ALL", StackMethod::ALL); + + nb::enum_(m, "NativeReportingMode") + .value("OFF", NativeReportingMode::OFF) + .value("PYTHON", NativeReportingMode::PYTHON) + .value("ALL", NativeReportingMode::ALL) + .value("LAST", NativeReportingMode::LAST); + + nb::class_(m, "CoreFileAnalyzer") + .def(nb::init< + const std::filesystem::path&, + std::optional, + std::optional>(), + "core_file"_a, + "executable"_a = nb::none(), + "lib_search_path"_a = nb::none()) + .def("extract_maps", &CoreFileAnalyzerWrapper::extract_maps) + .def("extract_pid", &CoreFileAnalyzerWrapper::extract_pid) + .def("extract_executable", &CoreFileAnalyzerWrapper::extract_executable) + .def("extract_failure_info", &CoreFileAnalyzerWrapper::extract_failure_info) + .def("extract_ps_info", &CoreFileAnalyzerWrapper::extract_ps_info) + .def("missing_modules", &CoreFileAnalyzerWrapper::missing_modules) + .def("extract_module_load_points", &CoreFileAnalyzerWrapper::extract_module_load_points) + .def("extract_build_ids", &CoreFileAnalyzerWrapper::extract_build_ids); + + nb::class_(m, "ProcessManager") + .def_static( + "create_from_pid", + &ProcessManagerWrapper::create_from_pid, + "pid"_a, + "stop_process"_a = true) + .def_static( + "create_from_core", + &ProcessManagerWrapper::create_from_core, + "core_file"_a, + "executable"_a, + "lib_search_path"_a = nb::none()) + .def("interpreter_status", &ProcessManagerWrapper::interpreter_status) + .def("is_interpreter_active", &ProcessManagerWrapper::is_interpreter_active) + .def_prop_ro("pid", &ProcessManagerWrapper::pid) + .def_prop_ro("python_version", &ProcessManagerWrapper::python_version) + .def( + "__enter__", + [](ProcessManagerWrapper& self) -> ProcessManagerWrapper& { return self; }, + nb::rv_policy::reference) + .def("__exit__", [](ProcessManagerWrapper& self, nb::args) { self.reset(); }); + + m.def("get_bss_info", &get_bss_info, "binary"_a, "Get BSS section information from an ELF binary"); + + // Note: We use nb::arg().none() to allow None to be passed explicitly + m.def( + "get_process_threads", + [](pid_t pid, + bool stop_process, + NativeReportingMode native_mode, + bool locals, + nb::object method_obj) { + if (method_obj.is_none()) { + throw std::invalid_argument("Invalid method for stack analysis"); + } + StackMethod method; + try { + method = nb::cast(method_obj); + } catch (const nb::cast_error&) { + throw std::invalid_argument("Invalid method for stack analysis"); + } + + try { + return get_process_threads(pid, stop_process, native_mode, locals, method); + } catch (const EngineError& e) { + raise_python_exception("EngineError", e.what(), pid); + } + }, + "pid"_a, + "stop_process"_a = true, + "native_mode"_a = NativeReportingMode::OFF, + "locals"_a = false, + nb::arg("method").none() = nb::cast(StackMethod::AUTO), + "Return an iterable of Thread objects from a live process"); + + m.def( + "get_process_threads_for_core", + [](const std::filesystem::path& core_file, + const std::filesystem::path& executable, + std::optional library_search_path, + NativeReportingMode native_mode, + bool locals, + nb::object method_obj) { + if (method_obj.is_none()) { + throw std::invalid_argument("Invalid method for stack analysis"); + } + StackMethod method; + try { + method = nb::cast(method_obj); + } catch (const nb::cast_error&) { + throw std::invalid_argument("Invalid method for stack analysis"); + } + + try { + return get_process_threads_for_core( + core_file, + executable, + library_search_path, + native_mode, + locals, + method); + } catch (const EngineError& e) { + raise_python_exception("EngineError", e.what(), std::nullopt, core_file); + } + }, + "core_file"_a, + "executable"_a, + "library_search_path"_a = nb::none(), + "native_mode"_a = NativeReportingMode::PYTHON, + "locals"_a = false, + nb::arg("method").none() = nb::cast(StackMethod::AUTO), + "Return an iterable of Thread objects from a core file"); + + m.def("_check_interpreter_shutdown", + &_check_interpreter_shutdown, + "manager"_a, + "Check interpreter shutdown status and log appropriately"); + + // intercept_runtime_errors decorator - re-export from pystack.errors + nb::module_ pystack_errors = nb::module_::import_("pystack.errors"); + m.attr("intercept_runtime_errors") = pystack_errors.attr("intercept_runtime_errors"); + + m.def("is_eval_frame", + &pystack::is_eval_frame, + "symbol"_a, + "python_version"_a, + "Return True if the symbol is a CPython eval frame function"); + + m.def( + "_copy_memory_for_testing", + [](pid_t pid, + pystack::remote_addr_t map_start, + pystack::remote_addr_t map_end, + const std::vector>& reads) { + std::vector maps; + maps.emplace_back(map_start, map_end, map_end - map_start, "rw-p", 0, "00:00", 0, ""); + pystack::ProcessMemoryManager manager(pid, maps); + nb::list result; + for (const auto& [address, size] : reads) { + std::vector buffer(size); + manager.copyMemoryFromProcess(address, size, buffer.data()); + result.append(nb::bytes(buffer.data(), buffer.size())); + } + return result; + }, + "pid"_a, + "map_start"_a, + "map_end"_a, + "reads"_a); + + m.def( + "_normalize_threads_for_testing", + [](nb::list thread_descs, + NativeReportingMode native_mode, + std::pair python_version) -> nb::list { + auto types = PyTypes::load(); + std::vector threads; + + for (auto desc_handle : thread_descs) { + nb::dict desc = nb::cast(desc_handle); + pystack::PyThreadData td{}; + td.tid = nb::cast(desc["tid"]); + td.stack_anchor = nb::cast(desc["stack_anchor"]); + td.interpreter_id = nb::cast(desc["interpreter_id"]); + + nb::list symbols = nb::cast(desc["native_symbols"]); + for (auto sym_handle : symbols) { + pystack::NativeFrame nf{}; + nf.symbol = nb::cast(sym_handle); + nf.path = "test.c"; + nf.linenumber = 1; + td.native_frames.push_back(nf); + } + + nb::list frames = nb::cast(desc["frames"]); + for (auto frame_handle : frames) { + auto frame_tuple = nb::cast(frame_handle); + pystack::PyFrameData fd{}; + fd.code.filename = "test.py"; + fd.code.scope = nb::cast(frame_tuple[0]); + fd.code.location = {1, 1, 0, 0}; + fd.is_entry = nb::cast(frame_tuple[1]); + td.frames.push_back(fd); + } + + threads.push_back(std::move(td)); + } + + auto normalized = normalizeThreads(std::move(threads), native_mode, python_version); + + nb::list result; + for (const auto& t : normalized) { + result.append(buildPyThreadObject(t, types, python_version)); + } + return result; + }, + "thread_descs"_a, + "native_mode"_a, + "python_version"_a); +} diff --git a/src/pystack/_pystack/corefile.cpp b/src/pystack/_pystack/corefile.cpp index 2c8e74f5..611976e5 100644 --- a/src/pystack/_pystack/corefile.cpp +++ b/src/pystack/_pystack/corefile.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -244,7 +245,7 @@ parseCoreSiginfo(const NoteData& note_data, CoreCrashInfo* result) } const size_t int_size = gelf_fsize(note_data.elf, ELF_T_WORD, 1, EV_CURRENT); - assert(int_size > 0); + const size_t addr_size = gelf_fsize(note_data.elf, ELF_T_ADDR, 1, EV_CURRENT); const char* ptr = static_cast(note_data.data->d_buf); read_obj(&ptr, &result->si_signo, int_size); @@ -262,8 +263,6 @@ parseCoreSiginfo(const NoteData& note_data, CoreCrashInfo* result) case SIGFPE: case SIGSEGV: case SIGBUS: { - const size_t addr_size = gelf_fsize(note_data.elf, ELF_T_ADDR, 1, EV_CURRENT); - assert(addr_size > 0); read_obj(&ptr, &result->failed_addr, addr_size); break; } @@ -279,55 +278,66 @@ parseCoreSiginfo(const NoteData& note_data, CoreCrashInfo* result) } static StatusCode -parseCoreFileNote(Elf* core, const NoteData& note_data, std::vector& result) +parseNtFileNote(const NoteData& note_data, std::vector& result) { Elf_Data* data = note_data.data; assert(data != NULL); - const size_t ulong_size = gelf_fsize(note_data.elf, ELF_T_ADDR, 1, EV_CURRENT); - if (ulong_size <= 0) { - LOG(ERROR) << "Cannot determine the size of 'long' for ELF file"; + + // The note holds a header of 2 longs, then N sets of 3 longs, then N null terminated names. + // See https://github.com/torvalds/linux/blob/v4.18/fs/binfmt_elf.c#L1594 + if (!data->d_buf || data->d_size < 2 * ulong_size) { + LOG(ERROR) << "Failed to parse file note data: note is too small"; return StatusCode::ERROR; } + const char* ptr = static_cast(data->d_buf); const char* end = static_cast(data->d_buf) + data->d_size; - uint64_t count, page_size; + uintptr_t count, page_size; read_obj(&ptr, &count, ulong_size); read_obj(&ptr, &page_size, ulong_size); - size_t addrsize = gelf_fsize(core, ELF_T_ADDR, 1, EV_CURRENT); - size_t entry_size = 3 * addrsize; // mstart, mend, moffset - uint64_t maxcount = (size_t)(end - ptr) / entry_size; + const size_t entry_size = 3 * ulong_size; // mstart, mend, moffset + const size_t maxcount = static_cast(end - ptr) / entry_size; if (count > maxcount) { LOG(ERROR) << "Failed to parse file note data: invalid number of entries"; return StatusCode::ERROR; } - // File names are stored at the end of the main table - const char* filename_table_start = ptr + count * entry_size; - const char* filename_table_ptr = filename_table_start; + const char* next_filename_start = ptr + count * entry_size; + std::vector parsed; + parsed.reserve(count); for (size_t i = 0; i < count; ++i) { // Read the data for a single entry - uint64_t mstart, mend, moffset; + uintptr_t mstart, mend, moffset; read_obj(&ptr, &mstart, ulong_size); read_obj(&ptr, &mend, ulong_size); read_obj(&ptr, &moffset, ulong_size); - // Fetch the corresponding file name from the file name table - std::string filename(filename_table_ptr); - result.emplace_back(CoreVirtualMap{mstart, mend, 0, "", moffset * page_size, "", 0, filename}); + uintptr_t offset; + if (__builtin_mul_overflow(moffset, page_size, &offset)) { + LOG(ERROR) << "Failed to parse file note data: file offset is out of range"; + return StatusCode::ERROR; + } - // Advance the file name table pointer - const char* next_filename = - static_cast(memchr(filename_table_ptr, '\0', end - filename_table_ptr)); - if (next_filename == nullptr) { + // Fetch the corresponding file name from the file name table + const char* next_filename_end = + static_cast(memchr(next_filename_start, '\0', end - next_filename_start)); + if (next_filename_end == nullptr) { LOG(ERROR) << "Failed to parse file note data: file name table ended too soon"; return StatusCode::ERROR; } - filename_table_ptr = next_filename + 1; + + std::string filename(next_filename_start, next_filename_end); + parsed.emplace_back(CoreVirtualMap{mstart, mend, 0, "", offset, "", 0, filename}); + + // Advance the pointer to the next entry in the file name table + next_filename_start = next_filename_end + 1; } + + result = std::move(parsed); // Only modify the caller's vector once we've succeeded. return StatusCode::SUCCESS; } @@ -339,12 +349,12 @@ CoreFileExtractor::extractMappedFiles() const LOG(DEBUG) << "Extracting mapped files from core file note"; for (const auto& note_data : getNoteData(elf, NT_FILE, ELF_T_XWORD)) { - if (parseCoreFileNote(elf, note_data, result) != StatusCode::ERROR) { + if (parseNtFileNote(note_data, result) != StatusCode::ERROR) { LOG(DEBUG) << "Mapped files found in core file note"; return result; } } - LOG(DEBUG) << "Mapped files could not be found in core file note"; + LOG(DEBUG) << "Mapped files could not be retrieved from core file note"; return result; } @@ -398,7 +408,10 @@ static StatusCode parseCoreExecfn(const NoteData& note_data, uintptr_t* result) { const size_t auxv_size = gelf_fsize(note_data.elf, ELF_T_AUXV, 1, EV_CURRENT); - assert(auxv_size > 0); + if (auxv_size == 0) { + LOG(ERROR) << "Cannot determine the size of an auxv entry for ELF file"; + return StatusCode::ERROR; + } const size_t nauxv = note_data.descriptor_size / auxv_size; for (size_t i = 0; i < nauxv; ++i) { GElf_auxv_t av_mem; @@ -416,41 +429,10 @@ uintptr_t CoreFileExtractor::findExecFn() const { LOG(DEBUG) << "Extracting ExecFn information"; - // If we have section headers, look for SHT_NOTE sections. - // In a core file, the program headers may not be reliable. + LOG(DEBUG) << "Attempting to get ExecFn from auxiliary vector"; Elf* elf = d_analyzer->d_elf.get(); uintptr_t result = 0; - size_t shnum; - - if (elf_getshdrnum(elf, &shnum) < 0) { - LOG(ERROR) << "Cannot determine the number of sections in the ELF file"; - return (uintptr_t) nullptr; - } - - LOG(DEBUG) << "Found " << shnum << " sections in the ELF file"; - - if (shnum != 0) { - Elf_Scn* scn = nullptr; - while ((scn = elf_nextscn(elf, scn)) != nullptr) { - GElf_Shdr shdr_mem; - GElf_Shdr* shdr = gelf_getshdr(scn, &shdr_mem); - if (shdr == nullptr || shdr->sh_type != SHT_NOTE) { - continue; - } - LOG(DEBUG) << "Valid SHT_NOTE segment found with offset " << std::hex << std::showbase - << shdr->sh_offset << ". Attempting to get ExecDn structure"; - Elf_Data* data = elf_getdata(scn, nullptr); - const NoteData note_data{elf, data, shdr->sh_offset}; - if (parseCoreExecfn(note_data, &result) != StatusCode::ERROR) { - LOG(DEBUG) << "ExecFn structure found"; - return result; - } - } - } - - LOG(DEBUG) << "Failed to locate the NOTE section via section headers"; - LOG(DEBUG) << "Attempting to get ExecFn from auxiliary vector"; for (const auto& note_data : getNoteData(elf, NT_AUXV, ELF_T_AUXV)) { if (parseCoreExecfn(note_data, &result) != StatusCode::ERROR) { @@ -459,7 +441,7 @@ CoreFileExtractor::findExecFn() const } } LOG(ERROR) << "Failed to extract the ExecFn information from the core file"; - return (uintptr_t) nullptr; + return (uintptr_t)nullptr; } std::vector CoreFileExtractor::missingModules() const diff --git a/src/pystack/_pystack/corefile.h b/src/pystack/_pystack/corefile.h index f59fda26..a24242c2 100644 --- a/src/pystack/_pystack/corefile.h +++ b/src/pystack/_pystack/corefile.h @@ -43,7 +43,7 @@ class CoreAnalyzerError : public std::exception { public: explicit CoreAnalyzerError(std::string error) - : d_error(std::move(error)){}; + : d_error(std::move(error)) {}; [[nodiscard]] const char* what() const noexcept override { diff --git a/src/pystack/_pystack/corefile.pxd b/src/pystack/_pystack/corefile.pxd deleted file mode 100644 index 629f77af..00000000 --- a/src/pystack/_pystack/corefile.pxd +++ /dev/null @@ -1,55 +0,0 @@ -from posix.types cimport pid_t - -from _pystack.elf_common cimport CoreFileAnalyzer -from _pystack.mem cimport SimpleVirtualMap -from libc.stdint cimport uintptr_t -from libcpp.memory cimport shared_ptr -from libcpp.string cimport string as cppstring -from libcpp.vector cimport vector - - -cdef extern from "corefile.h" namespace "pystack": - struct CoreCrashInfo: - int si_signo - int si_errno - int si_code - int sender_pid - int sender_uid - uintptr_t failed_addr - - struct CorePsInfo: - char state - char sname - char zomb - char nice - unsigned long flag - int uid - int gid - pid_t pid - pid_t ppid - pid_t pgrp - pid_t sid - char fname[16] - char psargs[80] - - struct CoreVirtualMap: - uintptr_t start - uintptr_t end - unsigned long filesize - cppstring flags - unsigned long offset - cppstring device - unsigned long inode - cppstring path - cppstring buildid - - cdef cppclass CoreFileExtractor: - CoreFileExtractor(shared_ptr[CoreFileAnalyzer] analyzer) except+ - int Pid() except+ - vector[CoreVirtualMap] MemoryMaps() except+ - vector[SimpleVirtualMap] ModuleInformation() except+ - cppstring extractExecutable() except+ - CoreCrashInfo extractFailureInfo() except+ - CorePsInfo extractPSInfo() except+ - vector[cppstring] missingModules() except+ - vector[CoreVirtualMap] extractMappedFiles() except+ diff --git a/src/pystack/_pystack/cpython/code.h b/src/pystack/_pystack/cpython/code.h index c3851d23..5069ffe7 100644 --- a/src/pystack/_pystack/cpython/code.h +++ b/src/pystack/_pystack/cpython/code.h @@ -160,13 +160,41 @@ typedef struct } PyCodeObject; } // namespace Python3_12 -typedef union { - Python2::PyCodeObject v2; - Python3_3::PyCodeObject v3_3; - Python3_6::PyCodeObject v3_6; - Python3_8::PyCodeObject v3_8; - Python3_11::PyCodeObject v3_11; - Python3_12::PyCodeObject v3_12; +namespace Python3_13 { +typedef uint16_t _Py_CODEUNIT; +typedef struct +{ + PyObject_VAR_HEAD; + PyObject* co_consts; + PyObject* co_names; + PyObject* co_exceptiontable; + int co_flags; + int co_argcount; + int co_posonlyargcount; + int co_kwonlyargcount; + int co_stacksize; + int co_firstlineno; + int co_nlocalsplus; + int co_framesize; + int co_nlocals; + int co_ncellvars; + int co_nfreevars; + uint32_t co_version; + PyObject* co_localsplusnames; + PyObject* co_localspluskinds; + PyObject* co_filename; + PyObject* co_name; + PyObject* co_qualname; + PyObject* co_linetable; + PyObject* co_weakreflist; + void* co_executors; + void* _co_cached; + uintptr_t _co_instrumentation_version; + void* _co_monitoring; + int _co_firsttraceable; + void* co_extra; + char co_code_adaptive[1]; } PyCodeObject; +} // namespace Python3_13 } // namespace pystack diff --git a/src/pystack/_pystack/cpython/dict.h b/src/pystack/_pystack/cpython/dict.h index fb2f3449..67e3804c 100644 --- a/src/pystack/_pystack/cpython/dict.h +++ b/src/pystack/_pystack/cpython/dict.h @@ -47,6 +47,11 @@ typedef struct PyObject** ma_values; } PyDictObject; +typedef struct _dictvalues +{ + PyObject* values[1]; +} PyDictValuesObject; + } // namespace Python3 namespace Python3_3 { typedef struct _dictkeysobject @@ -77,8 +82,21 @@ typedef struct _dictkeysobject uint32_t dk_version; Py_ssize_t dk_usable; Py_ssize_t dk_nentries; - char dk_indices[]; /* char is required to avoid strict aliasing. */ + char dk_indices[1]; /* char is required to avoid strict aliasing. */ } PyDictKeysObject; } // namespace Python3_11 +namespace Python3_13 { + +typedef struct _dictvalues +{ + uint8_t capacity; + uint8_t size; + uint8_t embedded; + uint8_t valid; + PyObject* values[1]; +} PyDictValuesObject; + +} // namespace Python3_13 + } // namespace pystack diff --git a/src/pystack/_pystack/cpython/frame.h b/src/pystack/_pystack/cpython/frame.h index ea0e297a..0951a252 100644 --- a/src/pystack/_pystack/cpython/frame.h +++ b/src/pystack/_pystack/cpython/frame.h @@ -39,7 +39,7 @@ namespace Python3_7 { typedef struct _pyframeobject { PyObject_VAR_HEAD struct _pyframeobject* f_back; - PyCodeObject* f_code; + PyObject* f_code; PyObject* f_builtins; PyObject* f_globals; PyObject* f_locals; @@ -64,7 +64,7 @@ typedef signed char PyFrameState; typedef struct _pyframeobject { PyObject_VAR_HEAD struct _pyframeobject* f_back; - PyCodeObject* f_code; + PyObject* f_code; PyObject* f_builtins; PyObject* f_globals; PyObject* f_locals; @@ -108,6 +108,13 @@ typedef struct _interpreter_frame namespace Python3_12 { typedef signed char PyFrameState; +enum _frameowner { + FRAME_OWNED_BY_THREAD = 0, + FRAME_OWNED_BY_GENERATOR = 1, + FRAME_OWNED_BY_FRAME_OBJECT = 2, + FRAME_OWNED_BY_CSTACK = 3, +}; + typedef struct _interpreter_frame { PyCodeObject* f_code; @@ -126,12 +133,20 @@ typedef struct _interpreter_frame } // namespace Python3_12 -typedef union { - Python2::PyFrameObject v2; - Python3_7::PyFrameObject v3_7; - Python3_10::PyFrameObject v3_10; - Python3_11::PyFrameObject v3_11; - Python3_12::PyFrameObject v3_12; -} PyFrameObject; +namespace Python3_14 { + +enum _frameowner { + FRAME_OWNED_BY_THREAD = 0, + FRAME_OWNED_BY_GENERATOR = 1, + FRAME_OWNED_BY_FRAME_OBJECT = 2, + FRAME_OWNED_BY_INTERPRETER = 3, + FRAME_OWNED_BY_CSTACK = 4, +}; + +typedef union _PyStackRef { + uintptr_t bits; +} _PyStackRef; + +} // namespace Python3_14 } // namespace pystack diff --git a/src/pystack/_pystack/cpython/gc.h b/src/pystack/_pystack/cpython/gc.h index cb040392..85e6b8bd 100644 --- a/src/pystack/_pystack/cpython/gc.h +++ b/src/pystack/_pystack/cpython/gc.h @@ -83,8 +83,4 @@ struct _gc_runtime_state } // namespace Python3_8 -typedef union { - struct Python3_7::_gc_runtime_state v3_7; - struct Python3_8::_gc_runtime_state v3_8; -} GCRuntimeState; } // namespace pystack diff --git a/src/pystack/_pystack/cpython/interpreter.h b/src/pystack/_pystack/cpython/interpreter.h index 7f6bf591..31382998 100644 --- a/src/pystack/_pystack/cpython/interpreter.h +++ b/src/pystack/_pystack/cpython/interpreter.h @@ -247,13 +247,96 @@ typedef struct _is } PyInterpreterState; } // namespace Python3_12 -typedef union { - Python2::PyInterpreterState v2; - Python3_5::PyInterpreterState v3_5; - Python3_7::PyInterpreterState v3_7; - Python3_8::PyInterpreterState v3_8; - Python3_9::PyInterpreterState v3_9; - Python3_11::PyInterpreterState v3_11; - Python3_12::PyInterpreterState v3_12; +namespace Python3_13 { + +struct _pythreadstate; /* Forward */ +struct PyMutex +{ + uint8_t v; +}; +typedef int (*_Py_pending_call_func)(void*); +struct _pending_call +{ + _Py_pending_call_func func; + void* arg; + int flags; +}; +struct _pending_calls +{ + void* handling_thread; + PyMutex mutex; + int32_t npending; + int32_t max; + int32_t maxloop; + struct _pending_call calls[300]; + int first; + int next; +}; + +struct _ceval_state +{ + uintptr_t instrumentation_version; + int recursion_limit; + struct _gil_runtime_state* gil; + int own_gil; + struct _pending_calls pending; +}; + +struct _import_state +{ + PyObject* modules; + PyObject* modules_by_index; + PyObject* importlib; + int override_frozen_modules; + int override_multi_interp_extensions_check; + int dlopenflags; + PyObject* import_func; + struct + { + PyThread_type_lock mutex; + unsigned long thread; + int level; + } lock; + struct + { + int import_level; + int64_t accumulated; + int header; + } find_and_load; +}; + +typedef struct _is +{ + struct _ceval_state ceval; + struct _is* next; + + int64_t id; + int64_t id_refcount; + int requires_idref; + PyThread_type_lock id_mutex; + long _whence; + int _initialized; + int _ready; + int finalizing; + uintptr_t last_restart_version; + struct pythreads + { + uint64_t next_unique_id; + _pythreadstate* head; + _pythreadstate* main; + Py_ssize_t count; + size_t stacksize; + } threads; + struct pyruntimestate* runtime; + void* _finalizing; + unsigned long _finalizing_id; + struct Python3_8::_gc_runtime_state gc; + // Dictionary of the sys module + PyObject* sysdict; + // Dictionary of the builtins module + PyObject* builtins; + struct _import_state imports; } PyInterpreterState; +} // namespace Python3_13 + } // namespace pystack diff --git a/src/pystack/_pystack/cpython/object.h b/src/pystack/_pystack/cpython/object.h index 15896cba..cc96e04e 100644 --- a/src/pystack/_pystack/cpython/object.h +++ b/src/pystack/_pystack/cpython/object.h @@ -205,12 +205,6 @@ typedef struct _typeobject } PyTypeObject; } // namespace Python3_8 -typedef union { - Python2::PyTypeObject v2; - Python3_3::PyTypeObject v3_3; - Python3_8::PyTypeObject v3_8; -} PyTypeObject; - /* These flags are used to determine if a type is a subclass. */ constexpr long Pystack_TPFLAGS_INT_SUBCLASS = 1ul << 23u; constexpr long Pystack_TPFLAGS_LONG_SUBCLASS = 1ul << 24u; diff --git a/src/pystack/_pystack/cpython/runtime.h b/src/pystack/_pystack/cpython/runtime.h index 1f14debc..79e4cbab 100644 --- a/src/pystack/_pystack/cpython/runtime.h +++ b/src/pystack/_pystack/cpython/runtime.h @@ -105,13 +105,15 @@ struct _ceval_runtime_state struct _gil_runtime_state gil; }; +struct PyThreadState; + typedef struct pyruntimestate { int preinitializing; int preinitialized; int core_initialized; int initialized; - PyThreadState* finalizing; + void* finalizing; struct pyinterpreters { @@ -171,7 +173,7 @@ typedef struct pyruntimestate int preinitialized; int core_initialized; int initialized; - PyThreadState* finalizing; + void* finalizing; struct pyinterpreters { @@ -246,12 +248,616 @@ typedef struct pyruntimestate } PyRuntimeState; } // namespace Python3_12 +namespace Python3_13 { + +typedef struct _Py_DebugOffsets +{ + char cookie[8]; + uint64_t version; + uint64_t free_threaded; + // Runtime state offset; + struct _runtime_state + { + uint64_t size; + uint64_t finalizing; + uint64_t interpreters_head; + } runtime_state; + + // Interpreter state offset; + struct _interpreter_state + { + uint64_t size; + uint64_t id; + uint64_t next; + uint64_t threads_head; + uint64_t gc; + uint64_t imports_modules; + uint64_t sysdict; + uint64_t builtins; + uint64_t ceval_gil; + uint64_t gil_runtime_state; + uint64_t gil_runtime_state_enabled; + uint64_t gil_runtime_state_locked; + uint64_t gil_runtime_state_holder; + } interpreter_state; + + // Thread state offset; + struct _thread_state + { + uint64_t size; + uint64_t prev; + uint64_t next; + uint64_t interp; + uint64_t current_frame; + uint64_t thread_id; + uint64_t native_thread_id; + uint64_t datastack_chunk; + uint64_t status; + } thread_state; + + // InterpreterFrame offset; + struct _interpreter_frame + { + uint64_t size; + uint64_t previous; + uint64_t executable; + uint64_t instr_ptr; + uint64_t localsplus; + uint64_t owner; + } interpreter_frame; + + // Code object offset; + struct _code_object + { + uint64_t size; + uint64_t filename; + uint64_t name; + uint64_t qualname; + uint64_t linetable; + uint64_t firstlineno; + uint64_t argcount; + uint64_t localsplusnames; + uint64_t localspluskinds; + uint64_t co_code_adaptive; + } code_object; + + // PyObject offset; + struct _pyobject + { + uint64_t size; + uint64_t ob_type; + } pyobject; + + // PyTypeObject object offset; + struct _type_object + { + uint64_t size; + uint64_t tp_name; + uint64_t tp_repr; + uint64_t tp_flags; + } type_object; + + // PyTuple object offset; + struct _tuple_object + { + uint64_t size; + uint64_t ob_item; + uint64_t ob_size; + } tuple_object; + + // PyList object offset; + struct _list_object + { + uint64_t size; + uint64_t ob_item; + uint64_t ob_size; + } list_object; + + // PyDict object offset; + struct _dict_object + { + uint64_t size; + uint64_t ma_keys; + uint64_t ma_values; + } dict_object; + + // PyFloat object offset; + struct _float_object + { + uint64_t size; + uint64_t ob_fval; + } float_object; + + // PyLong object offset; + struct _long_object + { + uint64_t size; + uint64_t lv_tag; + uint64_t ob_digit; + } long_object; + + // PyBytes object offset; + struct _bytes_object + { + uint64_t size; + uint64_t ob_size; + uint64_t ob_sval; + } bytes_object; + + // Unicode object offset; + struct _unicode_object + { + uint64_t size; + uint64_t state; + uint64_t length; + uint64_t asciiobject_size; + } unicode_object; + + // GC runtime state offset; + struct _gc + { + uint64_t size; + uint64_t collecting; + } gc; +} _Py_DebugOffsets; + +typedef struct pyruntimestate +{ + _Py_DebugOffsets debug_offsets; + int _initialized; + int preinitializing; + int preinitialized; + int core_initialized; + int initialized; + PyThreadState* finalizing; + unsigned long _finalizing_id; + + struct pyinterpreters + { + PyMutex mutex; + PyInterpreterState* head; + PyInterpreterState* main; + int64_t next_id; + } interpreters; +} PyRuntimeState; + +} // namespace Python3_13 + +namespace Python3_14 { + +typedef struct _Py_DebugOffsets +{ + char cookie[8]; + uint64_t version; + uint64_t free_threaded; + // Runtime state offset; + struct _runtime_state + { + uint64_t size; + uint64_t finalizing; + uint64_t interpreters_head; + } runtime_state; + + // Interpreter state offset; + struct _interpreter_state + { + uint64_t size; + uint64_t id; + uint64_t next; + uint64_t threads_head; + uint64_t threads_main; + uint64_t gc; + uint64_t imports_modules; + uint64_t sysdict; + uint64_t builtins; + uint64_t ceval_gil; + uint64_t gil_runtime_state; + uint64_t gil_runtime_state_enabled; + uint64_t gil_runtime_state_locked; + uint64_t gil_runtime_state_holder; + uint64_t code_object_generation; + uint64_t tlbc_generation; + } interpreter_state; + + // Thread state offset; + struct _thread_state + { + uint64_t size; + uint64_t prev; + uint64_t next; + uint64_t interp; + uint64_t current_frame; + uint64_t thread_id; + uint64_t native_thread_id; + uint64_t datastack_chunk; + uint64_t status; + } thread_state; + + // InterpreterFrame offset; + struct _interpreter_frame + { + uint64_t size; + uint64_t previous; + uint64_t executable; + uint64_t instr_ptr; + uint64_t localsplus; + uint64_t owner; + uint64_t stackpointer; + uint64_t tlbc_index; + } interpreter_frame; + + // Code object offset; + struct _code_object + { + uint64_t size; + uint64_t filename; + uint64_t name; + uint64_t qualname; + uint64_t linetable; + uint64_t firstlineno; + uint64_t argcount; + uint64_t localsplusnames; + uint64_t localspluskinds; + uint64_t co_code_adaptive; + uint64_t co_tlbc; + } code_object; + + // PyObject offset; + struct _pyobject + { + uint64_t size; + uint64_t ob_type; + } pyobject; + + // PyTypeObject object offset; + struct _type_object + { + uint64_t size; + uint64_t tp_name; + uint64_t tp_repr; + uint64_t tp_flags; + } type_object; + + // PyTuple object offset; + struct _tuple_object + { + uint64_t size; + uint64_t ob_item; + uint64_t ob_size; + } tuple_object; + + // PyList object offset; + struct _list_object + { + uint64_t size; + uint64_t ob_item; + uint64_t ob_size; + } list_object; + + // PySet object offset; + struct _set_object + { + uint64_t size; + uint64_t used; + uint64_t table; + uint64_t mask; + } set_object; + + // PyDict object offset; + struct _dict_object + { + uint64_t size; + uint64_t ma_keys; + uint64_t ma_values; + } dict_object; + + // PyFloat object offset; + struct _float_object + { + uint64_t size; + uint64_t ob_fval; + } float_object; + + // PyLong object offset; + struct _long_object + { + uint64_t size; + uint64_t lv_tag; + uint64_t ob_digit; + } long_object; + + // PyBytes object offset; + struct _bytes_object + { + uint64_t size; + uint64_t ob_size; + uint64_t ob_sval; + } bytes_object; + + // Unicode object offset; + struct _unicode_object + { + uint64_t size; + uint64_t state; + uint64_t length; + uint64_t asciiobject_size; + } unicode_object; + + // GC runtime state offset; + struct _gc + { + uint64_t size; + uint64_t collecting; + } gc; + + // Generator object offset; + struct _gen_object + { + uint64_t size; + uint64_t gi_name; + uint64_t gi_iframe; + uint64_t gi_frame_state; + } gen_object; + + struct _llist_node + { + uint64_t next; + uint64_t prev; + } llist_node; + + struct _debugger_support + { + uint64_t eval_breaker; + uint64_t remote_debugger_support; + uint64_t remote_debugging_enabled; + uint64_t debugger_pending_call; + uint64_t debugger_script_path; + uint64_t debugger_script_path_size; + } debugger_support; +} _Py_DebugOffsets; + +typedef struct pyruntimestate +{ + _Py_DebugOffsets debug_offsets; +} PyRuntimeState; + +} // namespace Python3_14 + +namespace Python3_15 { + +typedef struct _Py_DebugOffsets +{ + char cookie[8]; + uint64_t version; + uint64_t free_threaded; + // Runtime state offset; + struct _runtime_state + { + uint64_t size; + uint64_t finalizing; + uint64_t interpreters_head; + } runtime_state; + + // Interpreter state offset; + struct _interpreter_state + { + uint64_t size; + uint64_t id; + uint64_t next; + uint64_t threads_head; + uint64_t threads_main; + uint64_t gc; + uint64_t imports_modules; + uint64_t sysdict; + uint64_t builtins; + uint64_t ceval_gil; + uint64_t gil_runtime_state; + uint64_t gil_runtime_state_enabled; + uint64_t gil_runtime_state_locked; + uint64_t gil_runtime_state_holder; + uint64_t code_object_generation; + uint64_t tlbc_generation; + } interpreter_state; + + // Thread state offset; + struct _thread_state + { + uint64_t size; + uint64_t prev; + uint64_t next; + uint64_t interp; + uint64_t current_frame; + uint64_t base_frame; + uint64_t last_profiled_frame; + uint64_t last_profiled_frame_seq; // Added in 3.15.0b4 + uint64_t thread_id; + uint64_t native_thread_id; + uint64_t datastack_chunk; + uint64_t status; + uint64_t holds_gil; + uint64_t gil_requested; + uint64_t current_exception; + uint64_t exc_state; + } thread_state; + + // Exception stack item offset + struct + { + uint64_t exc_value; + } err_stackitem; + + // InterpreterFrame offset; + struct _interpreter_frame + { + uint64_t size; + uint64_t previous; + uint64_t executable; + uint64_t instr_ptr; + uint64_t localsplus; + uint64_t owner; + uint64_t stackpointer; + uint64_t tlbc_index; + } interpreter_frame; + + // Code object offset; + struct _code_object + { + uint64_t size; + uint64_t filename; + uint64_t name; + uint64_t qualname; + uint64_t linetable; + uint64_t firstlineno; + uint64_t argcount; + uint64_t localsplusnames; + uint64_t localspluskinds; + uint64_t co_code_adaptive; + uint64_t co_tlbc; + } code_object; + + // PyObject offset; + struct _pyobject + { + uint64_t size; + uint64_t ob_type; + } pyobject; + + // PyTypeObject object offset; + struct _type_object + { + uint64_t size; + uint64_t tp_name; + uint64_t tp_repr; + uint64_t tp_flags; + uint64_t tp_basicsize; + uint64_t tp_dictoffset; + } type_object; + + // PyHeapTypeObject offset; + struct _heap_type_object + { + uint64_t size; + uint64_t ht_cached_keys; + } heap_type_object; + + // PyTuple object offset; + struct _tuple_object + { + uint64_t size; + uint64_t ob_item; + uint64_t ob_size; + } tuple_object; + + // PyList object offset; + struct _list_object + { + uint64_t size; + uint64_t ob_item; + uint64_t ob_size; + } list_object; + + // PySet object offset; + struct _set_object + { + uint64_t size; + uint64_t used; + uint64_t table; + uint64_t mask; + } set_object; + + // PyDict object offset; + struct _dict_object + { + uint64_t size; + uint64_t ma_keys; + uint64_t ma_values; + } dict_object; + + // PyFloat object offset; + struct _float_object + { + uint64_t size; + uint64_t ob_fval; + } float_object; + + // PyLong object offset; + struct _long_object + { + uint64_t size; + uint64_t lv_tag; + uint64_t ob_digit; + } long_object; + + // PyBytes object offset; + struct _bytes_object + { + uint64_t size; + uint64_t ob_size; + uint64_t ob_sval; + } bytes_object; + + // Unicode object offset; + struct _unicode_object + { + uint64_t size; + uint64_t state; + uint64_t length; + uint64_t asciiobject_size; + uint64_t compactunicodeobject_size; + } unicode_object; + + // GC runtime state offset; + struct _gc + { + uint64_t size; + uint64_t collecting; + uint64_t frame; + uint64_t generation_stats_size; + uint64_t generation_stats; + } gc; + + // Generator object offset; + struct _gen_object + { + uint64_t size; + uint64_t gi_name; + uint64_t gi_iframe; + uint64_t gi_frame_state; + } gen_object; + + struct _llist_node + { + uint64_t next; + uint64_t prev; + } llist_node; + + struct _debugger_support + { + uint64_t eval_breaker; + uint64_t remote_debugger_support; + uint64_t remote_debugging_enabled; + uint64_t debugger_pending_call; + uint64_t debugger_script_path; + uint64_t debugger_script_path_size; + } debugger_support; +} _Py_DebugOffsets; + +typedef struct pyruntimestate +{ + _Py_DebugOffsets debug_offsets; +} PyRuntimeState; + +} // namespace Python3_15 + typedef union { Python3_7::PyRuntimeState v3_7; Python3_8::PyRuntimeState v3_8; Python3_9::PyRuntimeState v3_9; Python3_11::PyRuntimeState v3_11; Python3_12::PyRuntimeState v3_12; + Python3_13::PyRuntimeState v3_13; + Python3_14::PyRuntimeState v3_14; + Python3_15::PyRuntimeState v3_15; } PyRuntimeState; } // namespace pystack diff --git a/src/pystack/_pystack/cpython/string.h b/src/pystack/_pystack/cpython/string.h index 4fda2346..b6618c3c 100644 --- a/src/pystack/_pystack/cpython/string.h +++ b/src/pystack/_pystack/cpython/string.h @@ -12,11 +12,13 @@ typedef uint8_t Py_UCS1; typedef Py_UCS4 Py_UNICODE; typedef Py_ssize_t Py_hash_t; +namespace Python3 { typedef struct { PyObject_VAR_HEAD Py_hash_t ob_shash; char ob_sval[1]; } PyBytesObject; +} // namespace Python3 namespace Python2 { typedef struct @@ -37,19 +39,21 @@ typedef struct namespace Python3 { +struct _PyUnicode_State +{ + unsigned int interned : 2; + unsigned int kind : 3; + unsigned int compact : 1; + unsigned int ascii : 1; + unsigned int ready : 1; + unsigned int : 24; +}; + typedef struct { PyObject_HEAD Py_ssize_t length; Py_hash_t hash; - struct - { - unsigned int interned : 2; - unsigned int kind : 3; - unsigned int compact : 1; - unsigned int ascii : 1; - unsigned int ready : 1; - unsigned int : 24; - } state; + _PyUnicode_State state; wchar_t* wstr; } PyASCIIObject; @@ -76,19 +80,13 @@ typedef struct namespace Python3_12 { +using Python3::_PyUnicode_State; + typedef struct { PyObject_HEAD Py_ssize_t length; Py_hash_t hash; - struct - { - unsigned int interned : 2; - unsigned int kind : 3; - unsigned int compact : 1; - unsigned int ascii : 1; - unsigned int ready : 1; - unsigned int : 24; - } state; + _PyUnicode_State state; } PyASCIIObject; typedef struct @@ -100,6 +98,7 @@ typedef struct typedef struct { + PyCompactUnicodeObject _base; union { void* any; Py_UCS1* latin1; @@ -110,10 +109,22 @@ typedef struct } // namespace Python3_12 -typedef union { - Python2::PyUnicodeObject v2; - Python3::PyUnicodeObject v3; - Python3_12::PyUnicodeObject v3_12; -} PyUnicodeObject; +namespace Python3_14t { + +struct _PyUnicode_State +{ + unsigned char interned; + unsigned int kind : 3; + unsigned int compact : 1; + unsigned int ascii : 1; + unsigned int statically_allocated : 1; +}; + +} // namespace Python3_14t + +union AnyPyUnicodeState { + Python3::_PyUnicode_State python3; + Python3_14t::_PyUnicode_State python3_14t; +}; } // namespace pystack diff --git a/src/pystack/_pystack/cpython/thread.h b/src/pystack/_pystack/cpython/thread.h index 37592daf..c9b5da8d 100644 --- a/src/pystack/_pystack/cpython/thread.h +++ b/src/pystack/_pystack/cpython/thread.h @@ -233,17 +233,66 @@ typedef struct _pythreadstate } PyThreadState; } // namespace Python3_12 -typedef union { - Python2::PyThreadState v2; - Python3_4::PyThreadState v3_4; - Python3_7::PyThreadState v3_7; - Python3_11::PyThreadState v3_11; - Python3_12::PyThreadState v3_12; -} PyThreadState; +namespace Python3_13 { +typedef struct _err_stackitem +{ + PyObject* exc_value; + struct _err_stackitem* previous_item; +} _PyErr_StackItem; +typedef struct _pythreadstate +{ + struct _pythreadstate* prev; + struct _pythreadstate* next; + struct _is* interp; + uintptr_t eval_breaker; + struct + { + unsigned int initialized : 1; + unsigned int bound : 1; + unsigned int unbound : 1; + unsigned int bound_gilstate : 1; + unsigned int active : 1; + unsigned int finalizing : 1; + unsigned int cleared : 1; + unsigned int finalized : 1; + unsigned int : 24; + } _status; + int _whence; + int state; + int py_recursion_remaining; + int py_recursion_limit; + int c_recursion_remaining; + int recursion_headroom; + int tracing; + int what_event; + void* frame; + Py_tracefunc c_profilefunc; + Py_tracefunc c_tracefunc; + PyObject* c_profileobj; + PyObject* c_traceobj; + PyObject* current_exception; + _PyErr_StackItem* exc_info; + PyObject* dict; + int gilstate_counter; + PyObject* async_exc; + unsigned long thread_id; + unsigned long native_thread_id; -union CFrame { - Python3_11::CFrame v3_11; - Python3_12::CFrame v3_12; -}; + PyObject* delete_later; + uintptr_t critical_section; + int coroutine_origin_tracking_depth; + PyObject* async_gen_firstiter; + PyObject* async_gen_finalizer; + PyObject* context; + uint64_t context_ver; + uint64_t id; + void* datastack_chunk; + PyObject** datastack_top; + PyObject** datastack_limit; + _PyErr_StackItem exc_state; + PyObject* previous_executor; + uint64_t dict_global_version; +} PyThreadState; +} // namespace Python3_13 } // namespace pystack diff --git a/src/pystack/_pystack/elf_common.cpp b/src/pystack/_pystack/elf_common.cpp index c602a0ea..89b71716 100644 --- a/src/pystack/_pystack/elf_common.cpp +++ b/src/pystack/_pystack/elf_common.cpp @@ -1,8 +1,11 @@ +#include #include +#include #include #include #include #include +#include #include #include "compat.h" @@ -72,7 +75,8 @@ CoreFileAnalyzer::CoreFileAnalyzer( d_fd = open(d_filename.c_str(), O_RDONLY); if (d_fd == -1) { - throw ElfAnalyzerError("Failed to open ELF file " + d_filename); + throw ElfAnalyzerError( + "Failed to open ELF file '" + d_filename + "' (" + std::strerror(errno) + ")"); } d_elf = elf_unique_ptr(elf_begin(d_fd, ELF_C_READ_MMAP, nullptr), elf_end); @@ -81,6 +85,25 @@ CoreFileAnalyzer::CoreFileAnalyzer( throw ElfAnalyzerError("Cannot read elf file"); } + size_t ident_size = 0; + const char* ident = elf_getident(d_elf.get(), &ident_size); + if (ident == nullptr || ident_size <= EI_DATA) { + close(d_fd); + throw ElfAnalyzerError("Cannot read the ELF header of '" + d_filename + "'"); + } + + const auto core_endianness = static_cast(ident[EI_DATA]); + const bool endianness_matches = + (core_endianness == ELFDATA2LSB && std::endian::native == std::endian::little) + || (core_endianness == ELFDATA2MSB && std::endian::native == std::endian::big); + + const auto core_pointer_size = gelf_fsize(d_elf.get(), ELF_T_ADDR, 1, EV_CURRENT); + const bool pointer_size_matches = sizeof(uintptr_t) == core_pointer_size; + if (!endianness_matches || !pointer_size_matches) { + close(d_fd); + throw ElfAnalyzerError("The core file '" + d_filename + "' has an unsupported format."); + } + std::memset(&d_callbacks, 0, sizeof(d_callbacks)); d_callbacks.find_elf = pystack_find_elf; d_callbacks.find_debuginfo = dwfl_standard_find_debuginfo; @@ -339,9 +362,6 @@ getDataFromNoteSection( result.emplace_back(NoteData{elf, note_data, descr_size, desc_offset, note_contents}); } - if (result.empty()) { - LOG(DEBUG) << "Failed to locate NOTE of type " << note_type << " in the core file"; - } return result; } @@ -355,6 +375,9 @@ getNoteData(Elf* elf, Elf64_Word note_type, Elf_Type note_data_type) return {}; } + std::vector result; + bool found_pt_note = false; + // We have to look through the program header to find the note sections. // Note that there can be more than one. for (size_t program_header_idx = 0; program_header_idx < n_program_headers; ++program_header_idx) { @@ -364,6 +387,7 @@ getNoteData(Elf* elf, Elf64_Word note_type, Elf_Type note_data_type) if (program_header == nullptr || program_header->p_type != PT_NOTE) { continue; } + found_pt_note = true; LOG(DEBUG) << "Program header of type PT_NOTE found with offset " << std::hex << std::showbase << program_header->p_offset; Elf_Data* data = elf_getdata_rawchunk( @@ -381,10 +405,16 @@ getNoteData(Elf* elf, Elf64_Word note_type, Elf_Type note_data_type) LOG(DEBUG) << "Fetching data from NOTE segments of type " << note_type << " in program header with offset " << std::hex << std::showbase << program_header->p_offset; - return getDataFromNoteSection(elf, note_type, note_data_type, program_header, data); + auto notes = getDataFromNoteSection(elf, note_type, note_data_type, program_header, data); + result.insert(result.end(), notes.begin(), notes.end()); + } + if (!found_pt_note) { + LOG(ERROR) << "Failed to locate a program header of type PT_NOTE in the core file"; } - LOG(ERROR) << "Failed to locate a program header of type PT_NOTE in the core file"; - return {}; + if (result.empty()) { + LOG(DEBUG) << "Failed to locate NOTE of type " << note_type << " in the core file"; + } + return result; } bool @@ -398,7 +428,7 @@ getSectionInfo(const std::string& filename, const std::string& section_name, Sec LOG(DEBUG) << "Trying to locate .PyRuntime data offset from program headers"; file_unique_ptr file(fopen(filename.c_str(), "r"), fclose); if (!file || fileno(file.get()) == -1) { - LOG(ERROR) << "Cannot open ELF file " << filename; + LOG(ERROR) << "Cannot open ELF file " << filename << " (" << std::strerror(errno) << ")"; return false; } const int fd = fileno(file.get()); @@ -556,7 +586,7 @@ getBuildId(const std::string& filename) LOG(DEBUG) << "Trying to locate .PyRuntime data offset from program headers"; file_unique_ptr file(fopen(filename.c_str(), "r"), fclose); if (!file || fileno(file.get()) == -1) { - LOG(ERROR) << "Cannot open ELF file " << filename; + LOG(ERROR) << "Cannot open ELF file " << filename << " (" << std::strerror(errno) << ")"; return ""; } const int fd = fileno(file.get()); diff --git a/src/pystack/_pystack/elf_common.h b/src/pystack/_pystack/elf_common.h index 2004c00c..910e1eab 100644 --- a/src/pystack/_pystack/elf_common.h +++ b/src/pystack/_pystack/elf_common.h @@ -23,7 +23,7 @@ class ElfAnalyzerError : public std::exception { public: explicit ElfAnalyzerError(std::string error) - : d_error(std::move(error)){}; + : d_error(std::move(error)) {}; const char* what() const noexcept override { @@ -59,7 +59,7 @@ class CoreFileAnalyzer : public Analyzer std::string locateLibrary(const std::string& lib) const; // Destructors - ~CoreFileAnalyzer(); + virtual ~CoreFileAnalyzer(); // Data members dwfl_unique_ptr d_dwfl; @@ -84,6 +84,9 @@ class ProcessAnalyzer : public Analyzer // Constructors explicit ProcessAnalyzer(pid_t pid); + // Destructors + virtual ~ProcessAnalyzer() = default; + // Methods const dwfl_unique_ptr& getDwfl() const override; diff --git a/src/pystack/_pystack/elf_common.pxd b/src/pystack/_pystack/elf_common.pxd deleted file mode 100644 index 233ee822..00000000 --- a/src/pystack/_pystack/elf_common.pxd +++ /dev/null @@ -1,22 +0,0 @@ -from libc.stdint cimport uintptr_t -from libcpp.string cimport string as cppstring - - -cdef extern from "elf_common.h" namespace "pystack": - cdef cppclass ProcessAnalyzer: - ProcessAnalyzer(int pid) except+ - - cdef cppclass CoreFileAnalyzer: - CoreFileAnalyzer(cppstring filename) except+ - CoreFileAnalyzer(cppstring filename, cppstring executable) except+ - CoreFileAnalyzer(cppstring filename, cppstring executable, cppstring lib_search_path) except+ - - struct SectionInfo: - cppstring name - cppstring flags - uintptr_t addr - uintptr_t corrected_addr - size_t offset - size_t size - - int getSectionInfo(const cppstring& filename, const cppstring& section_name, SectionInfo* result) except+ diff --git a/src/pystack/_pystack/interpreter.cpp b/src/pystack/_pystack/interpreter.cpp new file mode 100644 index 00000000..78eef9ab --- /dev/null +++ b/src/pystack/_pystack/interpreter.cpp @@ -0,0 +1,34 @@ +#include + +#include "interpreter.h" +#include "process.h" +#include "structure.h" +#include "version.h" + +namespace pystack { + +remote_addr_t +InterpreterUtils::getNextInterpreter( + const std::shared_ptr& manager, + remote_addr_t interpreter_addr) +{ + Structure is(manager, interpreter_addr); + return is.getField(&py_is_v::o_next); +} + +int64_t +InterpreterUtils::getInterpreterId( + const std::shared_ptr& manager, + remote_addr_t interpreter_addr) +{ + if (!manager->versionIsAtLeast(3, 7)) { + // Interpreter ID was added in Python 3.7, so for earlier versions + // we just return the address as a unique identifier. + return static_cast(interpreter_addr); + } + + Structure is(manager, interpreter_addr); + return is.getField(&py_is_v::o_id); +} + +} // namespace pystack diff --git a/src/pystack/_pystack/interpreter.h b/src/pystack/_pystack/interpreter.h new file mode 100644 index 00000000..0138ff84 --- /dev/null +++ b/src/pystack/_pystack/interpreter.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +#include "mem.h" +#include "process.h" + +namespace pystack { + +class InterpreterUtils +{ + public: + // Static Methods + static remote_addr_t getNextInterpreter( + const std::shared_ptr& manager, + remote_addr_t interpreter_addr); + + static int64_t getInterpreterId( + const std::shared_ptr& manager, + remote_addr_t interpreter_addr); +}; + +} // namespace pystack diff --git a/src/pystack/_pystack/logging.cpp b/src/pystack/_pystack/logging.cpp index 4ac13a73..090d4bea 100644 --- a/src/pystack/_pystack/logging.cpp +++ b/src/pystack/_pystack/logging.cpp @@ -1,29 +1,135 @@ #include #include -#include "../_pystack_api.h" +#define PY_SSIZE_T_CLEAN +#include + #include "logging.h" namespace pystack { +static PyObject* g_logger = nullptr; static int LOGGER_INITIALIZED = false; void initializePythonLoggerInterface() { - import_pystack___pystack(); + if (LOGGER_INITIALIZED) { + return; + } + + // Import the logging module and get a logger + PyObject* logging_module = PyImport_ImportModule("logging"); + if (!logging_module) { + PyErr_Print(); + throw std::runtime_error("Failed to import logging module"); + } + + PyObject* getLogger = PyObject_GetAttrString(logging_module, "getLogger"); + if (!getLogger) { + Py_DECREF(logging_module); + PyErr_Print(); + throw std::runtime_error("Failed to get logging.getLogger"); + } + + // Get logger for pystack._pystack + PyObject* logger_name = PyUnicode_FromString("pystack._pystack"); + g_logger = PyObject_CallFunctionObjArgs(getLogger, logger_name, NULL); + Py_DECREF(logger_name); + Py_DECREF(getLogger); + Py_DECREF(logging_module); + + if (!g_logger) { + PyErr_Print(); + throw std::runtime_error("Failed to create logger"); + } + LOGGER_INITIALIZED = true; } +bool +isLoggingEnabled(logLevel level) +{ + if (!LOGGER_INITIALIZED || !g_logger) { + return false; + } + + PyGILState_STATE gstate = PyGILState_Ensure(); + + if (PyErr_Occurred()) { + PyGILState_Release(gstate); + return false; + } + + PyObject* result = PyObject_CallMethod(g_logger, "isEnabledFor", "i", static_cast(level)); + bool enabled = result && PyObject_IsTrue(result); + Py_XDECREF(result); + if (PyErr_Occurred()) { + PyErr_Clear(); + } + + PyGILState_Release(gstate); + return enabled; +} + void logWithPython(const std::string& message, int level) { - if (!LOGGER_INITIALIZED) { - throw std::runtime_error("Logger is not initialized"); + if (!LOGGER_INITIALIZED || !g_logger) { + return; } - if (!PyErr_Occurred()) { - log_with_python(&message, level); + + // Ensure we hold the GIL before calling any Python APIs, + // since LOG may be used from contexts where the GIL is released. + PyGILState_STATE gstate = PyGILState_Ensure(); + + if (PyErr_Occurred()) { + PyGILState_Release(gstate); + return; } + + // Get the log method name based on level + const char* method_name; + switch (level) { + case DEBUG: + method_name = "debug"; + break; + case INFO: + method_name = "info"; + break; + case WARNING: + method_name = "warning"; + break; + case ERROR: + method_name = "error"; + break; + case CRITICAL: + method_name = "critical"; + break; + default: + method_name = "info"; + break; + } + + // Call the log method + PyObject* py_message = PyUnicode_FromString(message.c_str()); + if (!py_message) { + PyErr_Clear(); + PyGILState_Release(gstate); + return; + } + + PyObject* result = PyObject_CallMethod(g_logger, method_name, "O", py_message); + Py_DECREF(py_message); + + if (!result) { + PyErr_Clear(); + PyGILState_Release(gstate); + return; + } + Py_DECREF(result); + + PyGILState_Release(gstate); } } // namespace pystack diff --git a/src/pystack/_pystack/logging.h b/src/pystack/_pystack/logging.h index 43d261b4..41022882 100644 --- a/src/pystack/_pystack/logging.h +++ b/src/pystack/_pystack/logging.h @@ -14,6 +14,9 @@ enum logLevel { CRITICAL = 50, }; +bool +isLoggingEnabled(logLevel level); + void logWithPython(const std::string& message, int level); @@ -22,7 +25,7 @@ class LOG public: // Constructors LOG() - : msgLevel(INFO){}; + : msgLevel(INFO) {}; explicit LOG(logLevel type) { diff --git a/src/pystack/_pystack/logging.pxd b/src/pystack/_pystack/logging.pxd deleted file mode 100644 index 5be77567..00000000 --- a/src/pystack/_pystack/logging.pxd +++ /dev/null @@ -1,2 +0,0 @@ -cdef extern from "logging.h" namespace "pystack": - void initializePythonLoggerInterface() diff --git a/src/pystack/_pystack/maps_parser.cpp b/src/pystack/_pystack/maps_parser.cpp new file mode 100644 index 00000000..2af314fd --- /dev/null +++ b/src/pystack/_pystack/maps_parser.cpp @@ -0,0 +1,363 @@ +#include "maps_parser.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logging.h" + +namespace pystack { + +namespace fs = std::filesystem; + +// Regex pattern for parsing /proc/pid/maps lines +// Format: start-end permissions offset dev inode pathname +static const std::regex MAPS_REGEXP( + R"(([0-9a-f]+)-([0-9a-f]+)\s+(.{4})\s+([0-9a-f]+)\s+([0-9a-f]+:[0-9a-f]+)\s+(\d+)\s*(.*)?)"); + +std::vector +parseProcMaps(pid_t pid) +{ + std::vector maps; + std::string maps_path = "/proc/" + std::to_string(pid) + "/maps"; + + std::ifstream maps_file(maps_path); + if (!maps_file.is_open()) { + throw std::runtime_error("No such process id: " + std::to_string(pid)); + } + + std::string line; + while (std::getline(maps_file, line)) { + std::smatch match; + if (!std::regex_match(line, match, MAPS_REGEXP)) { + LOG(DEBUG) << "Line cannot be recognized: " << line; + continue; + } + + uintptr_t start = std::stoull(match[1].str(), nullptr, 16); + uintptr_t end = std::stoull(match[2].str(), nullptr, 16); + std::string permissions = match[3].str(); + unsigned long offset = std::stoul(match[4].str(), nullptr, 16); + std::string device = match[5].str(); + unsigned long inode = std::stoul(match[6].str()); + std::string pathname = match[7].str(); + + size_t start_pos = pathname.find_first_not_of(" \t"); + if (start_pos != std::string::npos) { + pathname = pathname.substr(start_pos); + } else { + pathname = ""; + } + + maps.emplace_back( + start, + end, + end - start, // filesize + permissions, + offset, + device, + inode, + pathname); + } + + return maps; +} + +std::vector +parseCoreFileMaps( + const std::vector& mapped_files, + const std::vector& memory_maps) +{ + std::set> memory_map_ranges; + for (const auto& map : memory_maps) { + memory_map_ranges.insert({map.start, map.end}); + } + + std::vector missing_mapped_files; + for (const auto& map : mapped_files) { + if (memory_map_ranges.find({map.start, map.end}) == memory_map_ranges.end()) { + missing_mapped_files.push_back(map); + } + } + + std::vector all_maps; + all_maps.reserve(memory_maps.size() + missing_mapped_files.size()); + all_maps.insert(all_maps.end(), memory_maps.begin(), memory_maps.end()); + all_maps.insert(all_maps.end(), missing_mapped_files.begin(), missing_mapped_files.end()); + + std::sort(all_maps.begin(), all_maps.end(), [](const CoreVirtualMap& a, const CoreVirtualMap& b) { + return a.start < b.start; + }); + + std::set missing_map_paths; + for (const auto& map : missing_mapped_files) { + if (!map.path.empty()) { + try { + missing_map_paths.insert(fs::canonical(map.path).string()); + } catch (...) { + missing_map_paths.insert(map.path); + } + } + } + + std::unordered_map file_maps; + for (const auto& map : memory_maps) { + if (map.path.empty()) { + continue; + } + try { + std::string resolved_path = fs::canonical(map.path).string(); + if (missing_map_paths.count(resolved_path)) { + file_maps[resolved_path] = map.path; + } + } catch (...) { + // Ignore errors resolving paths + } + } + + std::vector result; + result.reserve(all_maps.size()); + for (const auto& elem : all_maps) { + std::string path = elem.path; + if (!path.empty()) { + std::string resolved; + try { + resolved = fs::canonical(path).string(); + } catch (...) { + resolved = path; + } + auto it = file_maps.find(resolved); + if (it != file_maps.end()) { + path = it->second; + } + } + result.emplace_back( + elem.start, + elem.end, + elem.filesize, + elem.flags, + elem.offset, + elem.device, + elem.inode, + path); + } + + return result; +} + +static VirtualMap +getBaseMap(const std::vector& binary_maps) +{ + for (const auto& map : binary_maps) { + if (!map.Path().empty()) { + return map; + } + } + if (!binary_maps.empty()) { + return binary_maps[0]; + } + throw std::runtime_error("No maps available"); +} + +static std::optional +getBss(const std::vector& elf_maps, uintptr_t load_point) +{ + if (elf_maps.empty()) { + return std::nullopt; + } + + VirtualMap binary_map = getBaseMap(elf_maps); + if (binary_map.Path().empty()) { + return std::nullopt; + } + + SectionInfo bss_info; + if (!getSectionInfo(binary_map.Path(), ".bss", &bss_info)) { + return std::nullopt; + } + + uintptr_t start = load_point + bss_info.corrected_addr; + LOG(INFO) << "Determined exact addr of .bss section: " << std::hex << start << " (" << load_point + << " + " << bss_info.corrected_addr << ")" << std::dec; + + unsigned long offset = 0; + + const VirtualMap* first_matching_map = nullptr; + for (const auto& map : elf_maps) { + if (map.containsAddr(start)) { + first_matching_map = ↦ + break; + } + } + + if (!first_matching_map) { + return std::nullopt; + } + + offset = first_matching_map->Offset() + (start - first_matching_map->Start()); + + return VirtualMap( + start, + start + bss_info.size, + bss_info.size, + "", // flags + offset, // offset + "", // device + 0, // inode + ""); // path +} + +ProcessMemoryMapInfo +parseMapInformation( + const std::string& binary, + const std::vector& maps, + const std::unordered_map* load_point_by_module) +{ + std::unordered_map> maps_by_library; + std::string current_lib; + + std::unordered_map computed_load_points; + if (!load_point_by_module) { + for (const auto& map : maps) { + if (!map.Path().empty()) { + std::string name = fs::path(map.Path()).filename().string(); + if (computed_load_points.find(name) == computed_load_points.end()) { + computed_load_points[name] = map.Start(); + } else { + computed_load_points[name] = std::min(computed_load_points[name], map.Start()); + } + } + } + load_point_by_module = &computed_load_points; + } + + for (const auto& memory_range : maps) { + std::string path_name; + if (!memory_range.Path().empty()) { + path_name = fs::path(memory_range.Path()).filename().string(); + current_lib = path_name; + } else { + path_name = current_lib; + } + maps_by_library[path_name].push_back(memory_range); + } + + std::string binary_name = fs::path(binary).filename().string(); + + auto python_it = maps_by_library.find(binary_name); + if (python_it == maps_by_library.end()) { + // Construct error message with available maps + std::ostringstream available; + for (const auto& map : maps) { + if (!map.Path().empty() && map.Path().find(".so") == std::string::npos) { + available << map.Path() << ", "; + } + } + std::string available_str = available.str(); + if (available_str.length() >= 2) { + available_str = available_str.substr(0, available_str.length() - 2); + } + throw std::runtime_error( + "Unable to find maps for the executable " + binary + + ". Available executable maps: " + available_str); + } + + const std::vector& binary_maps = python_it->second; + VirtualMap python = getBaseMap(binary_maps); + LOG(INFO) << "python binary first map found: " << python.Path(); + + std::optional libpython; + const std::vector* elf_maps = nullptr; + std::string libpython_name; + + std::vector libpython_binaries; + for (const auto& [lib_name, _] : maps_by_library) { + if (lib_name.find("libpython") != std::string::npos) { + libpython_binaries.push_back(lib_name); + } + } + + uintptr_t load_point = 0; + if (libpython_binaries.size() > 1) { + throw std::runtime_error( + "Unexpectedly found multiple libpython in process: " + + std::to_string(libpython_binaries.size())); + } else if (libpython_binaries.size() == 1) { + libpython_name = libpython_binaries[0]; + const auto& libpython_maps = maps_by_library[libpython_name]; + elf_maps = &libpython_maps; + auto load_it = load_point_by_module->find(libpython_name); + load_point = (load_it != load_point_by_module->end()) ? load_it->second : UINTPTR_MAX; + libpython = getBaseMap(libpython_maps); + LOG(INFO) << libpython_name << " first map found: " << libpython->Path(); + } else { + LOG(INFO) << "Process does not have a libpython.so, reading from binary"; + elf_maps = &binary_maps; + auto load_it = load_point_by_module->find(binary_name); + load_point = (load_it != load_point_by_module->end()) ? load_it->second : UINTPTR_MAX; + } + + std::optional heap; + auto heap_it = maps_by_library.find("[heap]"); + if (heap_it != maps_by_library.end() && !heap_it->second.empty()) { + heap = heap_it->second.front(); + LOG(INFO) << "Heap map found"; + } + + std::optional bss = getBss(*elf_maps, load_point); + if (!bss) { + for (const auto& map : *elf_maps) { + if (map.Path().empty() && map.Flags().find('r') != std::string::npos) { + bss = map; + break; + } + } + } + if (bss) { + LOG(INFO) << "bss map found"; + } + + return ProcessMemoryMapInfo{heap, bss, python, libpython}; +} + +ProcessMemoryMapInfo +parseMapInformationForProcess(pid_t pid, const std::vector& maps) +{ + std::string exe_link = "/proc/" + std::to_string(pid) + "/exe"; + char exe_path[PATH_MAX]; + ssize_t len = readlink(exe_link.c_str(), exe_path, sizeof(exe_path) - 1); + if (len == -1) { + throw std::runtime_error("Failed to read /proc/" + std::to_string(pid) + "/exe"); + } + exe_path[len] = '\0'; + return parseMapInformation(exe_path, maps); +} + +std::optional +getThreadName(pid_t pid, pid_t tid) +{ + std::string comm_path = "/proc/" + std::to_string(pid) + "/task/" + std::to_string(tid) + "/comm"; + std::ifstream comm_file(comm_path); + if (!comm_file.is_open()) { + return std::nullopt; + } + + std::string name; + std::getline(comm_file, name); + + size_t end = name.find_last_not_of(" \t\n\r"); + if (end != std::string::npos) { + name = name.substr(0, end + 1); + } + + return name; +} + +} // namespace pystack diff --git a/src/pystack/_pystack/maps_parser.h b/src/pystack/_pystack/maps_parser.h new file mode 100644 index 00000000..9678e9ce --- /dev/null +++ b/src/pystack/_pystack/maps_parser.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "corefile.h" +#include "elf_common.h" +#include "mem.h" + +namespace pystack { + +struct ProcessMemoryMapInfo +{ + std::optional heap; + std::optional bss; + VirtualMap python; + std::optional libpython; +}; + +std::vector +parseProcMaps(pid_t pid); + +std::vector +parseCoreFileMaps( + const std::vector& mapped_files, + const std::vector& memory_maps); + +ProcessMemoryMapInfo +parseMapInformation( + const std::string& binary, + const std::vector& maps, + const std::unordered_map* load_point_by_module = nullptr); + +ProcessMemoryMapInfo +parseMapInformationForProcess(pid_t pid, const std::vector& maps); + +std::optional +getThreadName(pid_t pid, pid_t tid); + +} // namespace pystack diff --git a/src/pystack/_pystack/mem.cpp b/src/pystack/_pystack/mem.cpp index dd74e0ae..430f1eb5 100644 --- a/src/pystack/_pystack/mem.cpp +++ b/src/pystack/_pystack/mem.cpp @@ -16,6 +16,8 @@ namespace pystack { +using elf_unique_ptr = std::unique_ptr>; + static ssize_t _process_vm_readv( pid_t pid, @@ -111,52 +113,9 @@ VirtualMap::Size() const return d_end - d_start; } -MemoryMapInformation::MemoryMapInformation() -: d_main_map(std::nullopt) -, d_bss(std::nullopt) -, d_heap(std::nullopt) -{ -} - -const std::optional& -MemoryMapInformation::MainMap() -{ - return d_main_map; -} - -const std::optional& -MemoryMapInformation::Bss() -{ - return d_bss; -} - -const std::optional& -MemoryMapInformation::Heap() -{ - return d_heap; -} - -void -MemoryMapInformation::setMainMap(const VirtualMap& main_map) -{ - d_main_map = main_map; -} - -void -MemoryMapInformation::setBss(const VirtualMap& bss) -{ - d_bss = bss; -} - -void -MemoryMapInformation::setHeap(const VirtualMap& heap) -{ - d_heap = heap; -} - LRUCache::LRUCache(size_t capacity) : d_cache_capacity(capacity) -, d_size(0){}; +, d_size(0) {}; void LRUCache::put(uintptr_t key, std::vector&& value) @@ -225,6 +184,16 @@ ProcessMemoryManager::ProcessMemoryManager(pid_t pid) ssize_t ProcessMemoryManager::readChunk(remote_addr_t addr, size_t len, char* dst) const +{ + if (d_memfile || getenv("_PYSTACK_NO_PROCESS_VM_READV") != nullptr) { + return readChunkThroughMemFile(addr, len, dst); + } else { + return readChunkDirect(addr, len, dst); + } +} + +ssize_t +ProcessMemoryManager::readChunkDirect(remote_addr_t addr, size_t len, char* dst) const { struct iovec local[1]; struct iovec remote[1]; @@ -243,6 +212,9 @@ ProcessMemoryManager::readChunk(remote_addr_t addr, size_t len, char* dst) const throw InvalidRemoteAddress(); } else if (errno == EPERM) { throw std::runtime_error(PERM_MESSAGE); + } else if (errno == ENOSYS) { + LOG(DEBUG) << "process_vm_readv not compiled in kernel, falling back to /proc/PID/mem"; + return readChunkThroughMemFile(addr, len, dst); } throw std::system_error(errno, std::generic_category()); } @@ -253,6 +225,30 @@ ProcessMemoryManager::readChunk(remote_addr_t addr, size_t len, char* dst) const return result; } +ssize_t +ProcessMemoryManager::readChunkThroughMemFile(remote_addr_t addr, size_t len, char* dst) const +{ + if (!d_memfile) { + std::string filepath = "/proc/" + std::to_string(d_pid) + "/mem"; + d_memfile = file_unique_ptr(fopen(filepath.c_str(), "r"), fclose); + if (!d_memfile) { + if (errno == EPERM || errno == EACCES) { + LOG(ERROR) << "Permission denied opening file " << filepath; + throw std::runtime_error(PERM_MESSAGE); + } + LOG(ERROR) << "Failed to open file " << filepath << ": " << std::strerror(errno); + throw std::runtime_error("Failed to open " + filepath); + } + } + fseeko(d_memfile.get(), addr, SEEK_SET); + if (static_cast(addr) != ftello(d_memfile.get()) + || len != fread(dst, 1, len, d_memfile.get())) + { + throw InvalidRemoteAddress(); + } + return static_cast(len); +} + ssize_t ProcessMemoryManager::copyMemoryFromProcess(remote_addr_t addr, size_t len, void* dst) const { @@ -260,7 +256,9 @@ ProcessMemoryManager::copyMemoryFromProcess(remote_addr_t addr, size_t len, void return vmap.containsAddr(addr) && vmap.containsAddr(addr + len - 1); }); - if (vmap == d_vmaps.end() || !d_lru_cache.can_fit(vmap->Size())) { + if (vmap == d_vmaps.end() || !d_lru_cache.can_fit(vmap->Size()) + || d_uncacheable_vmaps.contains(vmap->Start())) + { return readChunk(addr, len, reinterpret_cast(dst)); } @@ -271,8 +269,15 @@ ProcessMemoryManager::copyMemoryFromProcess(remote_addr_t addr, size_t len, void if (!d_lru_cache.exists(key)) { std::vector buf(chunk_size); - readChunk(vmap_start_addr, chunk_size, buf.data()); - d_lru_cache.put(key, std::move(buf)); + try { + readChunk(vmap_start_addr, chunk_size, buf.data()); + d_lru_cache.put(key, std::move(buf)); + } catch (const InvalidRemoteAddress&) { + // The full vmap read failed (e.g. guard pages in JIT mappings). + // Future reads from this vmap should avoid the same failing cache fill. + d_uncacheable_vmaps.insert(key); + return readChunk(addr, len, reinterpret_cast(dst)); + } } std::memcpy(dst, d_lru_cache.get(key).data() + offset_addr, len); @@ -283,7 +288,7 @@ ProcessMemoryManager::copyMemoryFromProcess(remote_addr_t addr, size_t len, void bool ProcessMemoryManager::isAddressValid(remote_addr_t addr, const VirtualMap& map) const { - if (addr == (uintptr_t) nullptr) { + if (addr == (uintptr_t)nullptr) { return false; } return map.Start() <= addr && addr < map.End(); @@ -368,7 +373,7 @@ CorefileRemoteMemoryManager::copyMemoryFromProcess(remote_addr_t addr, size_t si StatusCode ret = getMemoryLocationFromCore(addr, &offset_in_file); if (ret == StatusCode::SUCCESS) { - if (static_cast(offset_in_file) > d_corefile_size) { + if (size > d_corefile_size || static_cast(offset_in_file) > d_corefile_size - size) { throw InvalidRemoteAddress(); } memcpy(destination, d_corefile_data.get() + offset_in_file, size); @@ -398,17 +403,61 @@ CorefileRemoteMemoryManager::StatusCode CorefileRemoteMemoryManager::getMemoryLocationFromCore(remote_addr_t addr, off_t* offset_in_file) const { auto corefile_it = std::find_if(d_vmaps.cbegin(), d_vmaps.cend(), [&](auto& map) { - return (map.Start() <= addr && addr <= map.End()) && (map.FileSize() != 0 && map.Offset() != 0); + // When considering if the data is in the core file, we need to check if the address is + // within the chunk of the segment in the core file. map.End() corresponds + // to the end of the segment in memory when the process was alive but when the core was + // created not all that data will be in the core, so we need to use map.FileSize() + // to get the end of the segment in the core file. + uintptr_t fileEnd = map.Start() + map.FileSize(); + return (map.Start() <= addr && addr < fileEnd) && (map.FileSize() != 0 && map.Offset() != 0); }); if (corefile_it == d_vmaps.cend()) { return StatusCode::ERROR; } - unsigned long base = corefile_it->Offset() - corefile_it->Start(); - *offset_in_file = base + addr; + *offset_in_file = static_cast(corefile_it->Offset() + (addr - corefile_it->Start())); return StatusCode::SUCCESS; } +CorefileRemoteMemoryManager::StatusCode +CorefileRemoteMemoryManager::initLoadSegments(const std::string& filename) const +{ + file_unique_ptr file(fopen(filename.c_str(), "r"), fclose); + if (!file || fileno(file.get()) == -1) { + return StatusCode::ERROR; + } + + auto elf = elf_unique_ptr(elf_begin(fileno(file.get()), ELF_C_READ_MMAP, nullptr), elf_end); + if (!elf) { + return StatusCode::ERROR; + } + + std::vector segments; + size_t phnum; + if (elf_getphdrnum(elf.get(), &phnum) == 0) { + for (size_t i = 0; i < phnum; i++) { + GElf_Phdr phdr_mem; + GElf_Phdr* phdr = gelf_getphdr(elf.get(), i, &phdr_mem); + if (phdr == nullptr) { + LOG(WARNING) << "Failed to read program header " << i << " from " << filename.c_str() + << " (" << elf_errmsg(elf_errno()) << ")"; + continue; + } + + if (phdr->p_type == PT_LOAD) { + segments.push_back( + {.vaddr = phdr->p_vaddr, .offset = phdr->p_offset, .size = phdr->p_filesz}); + } + } + } + + if (!segments.empty()) { + d_elf_load_segments_cache[filename] = std::move(segments); + return StatusCode::SUCCESS; + } + return StatusCode::ERROR; +} + CorefileRemoteMemoryManager::StatusCode CorefileRemoteMemoryManager::getMemoryLocationFromElf( remote_addr_t addr, @@ -416,20 +465,50 @@ CorefileRemoteMemoryManager::getMemoryLocationFromElf( off_t* offset_in_file) const { auto shared_libs_it = std::find_if(d_shared_libs.cbegin(), d_shared_libs.cend(), [&](auto& map) { - return map.start <= addr && addr <= map.end; + return map.start <= addr && addr < map.end; }); + if (shared_libs_it == d_shared_libs.cend()) { return StatusCode::ERROR; } + *filename = &shared_libs_it->filename; - *offset_in_file = addr - shared_libs_it->start; - return StatusCode::SUCCESS; + + // Check if we have cached segments for this file + auto cache_it = d_elf_load_segments_cache.find(**filename); + if (cache_it == d_elf_load_segments_cache.end()) { + // Initialize segments if not in cache + if (initLoadSegments(**filename) != StatusCode::SUCCESS) { + return StatusCode::ERROR; + } + cache_it = d_elf_load_segments_cache.find(**filename); + } + + // Get the load address of the elf file from its first segment + remote_addr_t elf_load_addr = cache_it->second[0].vaddr; + + // Now relocate the address to the elf file + remote_addr_t symbol_vaddr = addr - shared_libs_it->start + elf_load_addr; + + // Find the segment containing this address + for (const auto& segment : cache_it->second) { + if (symbol_vaddr >= segment.vaddr && symbol_vaddr < segment.vaddr + segment.size) { + *offset_in_file = (symbol_vaddr - segment.vaddr) + segment.offset; + return StatusCode::SUCCESS; + } + } + + LOG(ERROR) << "Failed to find the correct segment for address " << std::hex << std::showbase << addr + << " (with vaddr offset " << symbol_vaddr << ")" + << " in file " << **filename; + + return StatusCode::ERROR; } bool CorefileRemoteMemoryManager::isAddressValid(remote_addr_t addr, const VirtualMap& map) const { - if (addr == (uintptr_t) nullptr) { + if (addr == (uintptr_t)nullptr) { return false; } return map.Start() <= addr && addr < map.Start() + map.Size(); diff --git a/src/pystack/_pystack/mem.h b/src/pystack/_pystack/mem.h index 6baca6d7..1d31a1ba 100644 --- a/src/pystack/_pystack/mem.h +++ b/src/pystack/_pystack/mem.h @@ -10,11 +10,14 @@ #include #include #include +#include #include -#include +#include "elf_common.h" namespace pystack { + +using file_unique_ptr = std::unique_ptr>; typedef uintptr_t remote_addr_t; struct RemoteMemCopyError : public std::exception @@ -79,6 +82,27 @@ class VirtualMap // Methods bool containsAddr(remote_addr_t addr) const; + // Permission helpers + bool isExecutable() const + { + return d_flags.find('x') != std::string::npos; + } + + bool isReadable() const + { + return d_flags.find('r') != std::string::npos; + } + + bool isWritable() const + { + return d_flags.find('w') != std::string::npos; + } + + bool isPrivate() const + { + return d_flags.find('p') != std::string::npos; + } + private: // Data members uintptr_t d_start{}; @@ -91,29 +115,6 @@ class VirtualMap std::string d_path{}; }; -class MemoryMapInformation -{ - public: - MemoryMapInformation(); - - // Getters - const std::optional& MainMap(); - const std::optional& Bss(); - const std::optional& Heap(); - - // Setters - - void setMainMap(const VirtualMap& main_map); - void setBss(const VirtualMap& bss); - void setHeap(const VirtualMap& heap); - - private: - // Data members - std::optional d_main_map; - std::optional d_bss; - std::optional d_heap; -}; - class LRUCache { private: @@ -174,9 +175,13 @@ class ProcessMemoryManager : public AbstractRemoteMemoryManager pid_t d_pid; std::vector d_vmaps; mutable LRUCache d_lru_cache; + mutable std::unordered_set d_uncacheable_vmaps; + mutable file_unique_ptr d_memfile; // Methods ssize_t readChunk(remote_addr_t addr, size_t len, char* dst) const; + ssize_t readChunkDirect(remote_addr_t addr, size_t len, char* dst) const; + ssize_t readChunkThroughMemFile(remote_addr_t addr, size_t len, char* dst) const; }; struct SimpleVirtualMap @@ -207,6 +212,15 @@ class CorefileRemoteMemoryManager : public AbstractRemoteMemoryManager ERROR, }; + struct ElfLoadSegment + { + GElf_Addr vaddr; + GElf_Off offset; + GElf_Xword size; + }; + // Cache for PT_LOAD segments + mutable std::unordered_map> d_elf_load_segments_cache; + // Data members std::shared_ptr d_analyzer; std::vector d_vmaps; @@ -220,5 +234,6 @@ class CorefileRemoteMemoryManager : public AbstractRemoteMemoryManager remote_addr_t addr, const std::string** filename, off_t* offset_in_file) const; + StatusCode initLoadSegments(const std::string& filename) const; }; } // namespace pystack diff --git a/src/pystack/_pystack/mem.pxd b/src/pystack/_pystack/mem.pxd deleted file mode 100644 index 02cfcce3..00000000 --- a/src/pystack/_pystack/mem.pxd +++ /dev/null @@ -1,33 +0,0 @@ -from libc.stdint cimport uintptr_t -from libcpp.string cimport string as cppstring -from libcpp.vector cimport vector - - -cdef extern from "mem.h" namespace "pystack": - ctypedef uintptr_t remote_addr_t - - cdef cppclass AbstractRemoteMemoryManager: - ssize_t copyMemoryFromProcess(remote_addr_t addr, size_t size, void *destination) except+ - - cdef cppclass ProcessMemoryManager(AbstractRemoteMemoryManager): - ProcessMemoryManager(int pid) except+ - ssize_t copyMemoryFromProcess(remote_addr_t addr, size_t size, void *destination) except+ - - - struct SimpleVirtualMap: - uintptr_t start - uintptr_t end - cppstring filename - cppstring buildid - - cdef cppclass VirtualMap: - VirtualMap() - VirtualMap(uintptr_t start, uintptr_t end, unsigned long filesize, - cppstring flags, unsigned long offset, cppstring permissions, - unsigned long inode, cppstring pathname) - - cdef cppclass MemoryMapInformation: - MemoryMapInformation() - void setMainMap(const VirtualMap& bss) - void setBss(const VirtualMap& bss) - void setHeap(const VirtualMap& heap) diff --git a/src/pystack/_pystack/native_frame.cpp b/src/pystack/_pystack/native_frame.cpp new file mode 100644 index 00000000..754bf5e6 --- /dev/null +++ b/src/pystack/_pystack/native_frame.cpp @@ -0,0 +1,29 @@ +#include "native_frame.h" + +#include + +namespace pystack { + +static bool +starts_with(const std::string& str, const std::string& prefix) +{ + return str.rfind(prefix, 0) == 0; +} + +bool +is_eval_frame(const std::string& symbol, std::pair python_version) +{ + if (python_version < std::make_pair(3, 6)) { + return symbol.find("PyEval_EvalFrameEx") != std::string::npos; + } + if (symbol.find("_PyEval_EvalFrameDefault") != std::string::npos) { + return true; + } + // Match the _TAIL_CALL_ prefix for Python 3.14+. + if (python_version >= std::make_pair(3, 14) and starts_with(symbol, "_TAIL_CALL_")) { + return true; + } + return false; +} + +} // namespace pystack diff --git a/src/pystack/_pystack/native_frame.h b/src/pystack/_pystack/native_frame.h index f98118a8..a462cc5a 100644 --- a/src/pystack/_pystack/native_frame.h +++ b/src/pystack/_pystack/native_frame.h @@ -1,6 +1,7 @@ #pragma once #include +#include namespace pystack { // The reason this is a struct is so Cython can easily generate @@ -14,4 +15,8 @@ struct NativeFrame int colnumber; std::string library; }; + +bool +is_eval_frame(const std::string& symbol, std::pair python_version); + } // namespace pystack diff --git a/src/pystack/_pystack/native_frame.pxd b/src/pystack/_pystack/native_frame.pxd deleted file mode 100644 index 4aa13188..00000000 --- a/src/pystack/_pystack/native_frame.pxd +++ /dev/null @@ -1,11 +0,0 @@ -from libcpp.string cimport string as cppstring - - -cdef extern from "native_frame.h" namespace "pystack": - struct NativeFrame: - unsigned long address - cppstring symbol - cppstring path - int linenumber - int colnumber - cppstring library diff --git a/src/pystack/_pystack/process.cpp b/src/pystack/_pystack/process.cpp index 37901e21..af24a76f 100644 --- a/src/pystack/_pystack/process.cpp +++ b/src/pystack/_pystack/process.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -13,6 +14,7 @@ #include "corefile.h" #include "logging.h" +#include "maps_parser.h" #include "mem.h" #include "native_frame.h" #include "process.h" @@ -22,6 +24,7 @@ #include "pythread.h" #include "pytypes.h" #include "version.h" +#include "version_detector.h" namespace { @@ -63,6 +66,74 @@ class DirectoryReader } // namespace namespace pystack { +namespace fs = std::filesystem; + +namespace { + +// Helper to extract the main interpreter map from ProcessMemoryMapInfo +std::optional +getMainMap(const ProcessMemoryMapInfo& map_info) +{ + return map_info.libpython ? *map_info.libpython : map_info.python; +} + +} // namespace + +namespace { // unnamed + +struct ParsedPyVersion +{ + int major; + int minor; + int patch; + const char* release_level; + int serial; +}; + +std::ostream& +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[0]) { + oss << version.release_level << version.serial; + } + + out << oss.str(); + return out; +} + +bool +parsePyVersionHex(uint64_t version, ParsedPyVersion& parsed) +{ + int major = (version >> 24) & 0xFF; + int minor = (version >> 16) & 0xFF; + int patch = (version >> 8) & 0xFF; + int level = (version >> 4) & 0x0F; + int count = (version >> 0) & 0x0F; + + const char* level_str = "(unknown release level)"; + if (level == 0xA) { + level_str = "a"; + } else if (level == 0xB) { + level_str = "b"; + } else if (level == 0xC) { + level_str = "rc"; + } else if (level == 0xF) { + level_str = ""; + } + + if (major < 2 || major > 3 || level_str == nullptr || (level == 0xF && count != 0)) { + return false; // Doesn't look valid. + } + + parsed = ParsedPyVersion{major, minor, patch, level_str, count}; + return true; +} + +} // unnamed namespace + static std::vector getProcessTids(pid_t pid) { @@ -158,21 +229,35 @@ ProcessTracer::getTids() const AbstractProcessManager::AbstractProcessManager( pid_t pid, std::vector&& memory_maps, - MemoryMapInformation&& map_info) + std::optional main_map, + std::optional bss, + std::optional heap) : d_pid(pid) -, d_memory_maps(memory_maps) +, d_main_map(std::move(main_map)) +, d_bss(std::move(bss)) +, d_heap(std::move(heap)) +, d_memory_maps(std::move(memory_maps)) , d_manager(nullptr) , d_unwinder(nullptr) , d_analyzer(nullptr) { - d_main_map = map_info.MainMap(); - d_bss = map_info.Bss(); - d_heap = map_info.Heap(); if (!d_main_map) { throw std::runtime_error("The main interpreter map could not be located"); } } +const std::vector& +AbstractProcessManager::MemoryMaps() const +{ + return d_memory_maps; +} + +std::pair +AbstractProcessManager::Version() const +{ + return std::make_pair(d_major, d_minor); +} + bool AbstractProcessManager::isValidDictionaryObject(remote_addr_t addr) const { @@ -234,30 +319,30 @@ AbstractProcessManager::isValidInterpreterState(remote_addr_t addr) const return false; } - PyInterpreterState is; + Structure is(shared_from_this(), addr); // The check for valid addresses may fail if the address falls in the stack // space (there are "holes" in the address map space so just checking for // min_addr < addr < max_addr does not guarantee a valid address) so we need // to catch InvalidRemoteAddress exceptions. try { - copyObjectFromProcess(addr, &is); + is.copyFromRemote(); } catch (RemoteMemCopyError& ex) { return false; } - PyThreadState current_thread; - auto current_thread_addr = getField(is, &py_is_v::o_tstate_head); + auto current_thread_addr = is.getField(&py_is_v::o_tstate_head); if (!isAddressValid(current_thread_addr)) { return false; } + Structure current_thread(shared_from_this(), current_thread_addr); try { - copyObjectFromProcess(current_thread_addr, ¤t_thread); + current_thread.copyFromRemote(); } catch (RemoteMemCopyError& ex) { return false; } - if (getField(current_thread, &py_thread_v::o_interp) != addr) { + if (current_thread.getField(&py_thread_v::o_interp) != addr) { return false; } @@ -266,9 +351,9 @@ AbstractProcessManager::isValidInterpreterState(remote_addr_t addr) const // Validate dictionaries in the interpreter state std::unordered_map dictionaries( - {{"modules", getField(is, &py_is_v::o_modules)}, - {"sysdict", getField(is, &py_is_v::o_sysdict)}, - {"builtins", getField(is, &py_is_v::o_builtins)}}); + {{"modules", is.getField(&py_is_v::o_modules)}, + {"sysdict", is.getField(&py_is_v::o_sysdict)}, + {"builtins", is.getField(&py_is_v::o_builtins)}}); for (const auto& [dictname, addr] : dictionaries) { if (!isValidDictionaryObject(addr)) { LOG(DEBUG) << "The '" << dictname << "' dictionary object is not valid"; @@ -304,9 +389,8 @@ AbstractProcessManager::findInterpreterStateFromPyRuntime(remote_addr_t runtime_ LOG(INFO) << "Searching for PyInterpreterState based on PyRuntime address " << std::hex << std::showbase << runtime_addr; - PyRuntimeState py_runtime; - copyObjectFromProcess(runtime_addr, &py_runtime); - remote_addr_t interp_state = getField(py_runtime, &py_runtime_v::o_interp_head); + Structure py_runtime(shared_from_this(), runtime_addr); + remote_addr_t interp_state = py_runtime.getField(&py_runtime_v::o_interp_head); if (!isValidInterpreterState(interp_state)) { LOG(INFO) << "Failing to resolve PyInterpreterState based on PyRuntime address " << std::hex @@ -353,6 +437,40 @@ AbstractProcessManager::scanMemoryAreaForInterpreterState(const VirtualMap& map) return (remote_addr_t)result; } +remote_addr_t +AbstractProcessManager::scanMemoryAreaForDebugOffsets(const VirtualMap& map) const +{ + size_t size = map.Size(); + std::vector memory_buffer(size); + remote_addr_t base = map.Start(); + copyMemoryFromProcess(base, size, memory_buffer.data()); + + LOG(INFO) << std::showbase << std::hex << "Searching for debug offsets in memory area spanning from " + << map.Start() << " to " << map.End(); + + uint64_t* lower_bound = (uint64_t*)&memory_buffer.data()[0]; + uint64_t* upper_bound = (uint64_t*)&memory_buffer.data()[size]; + + uint64_t cookie; + memcpy(&cookie, "xdebugpy", sizeof(cookie)); + + for (uint64_t* raddr = lower_bound; raddr < upper_bound; raddr++) { + if (raddr[0] == cookie) { + uint64_t version = raddr[1]; + + ParsedPyVersion parsed; + if (parsePyVersionHex(version, parsed) && parsed.major == 3 && parsed.minor >= 13) { + auto offset = (remote_addr_t)raddr - (remote_addr_t)memory_buffer.data(); + auto addr = offset + base; + LOG(DEBUG) << std::hex << std::showbase << "Possible debug offsets found at address " + << addr << " in a mapping of " << map.Path(); + return addr; + } + } + } + return 0; +} + remote_addr_t AbstractProcessManager::scanBSS() const { @@ -393,6 +511,27 @@ AbstractProcessManager::scanHeap() const return scanMemoryAreaForInterpreterState(d_heap.value()); } +remote_addr_t +AbstractProcessManager::findDebugOffsetsFromMaps() const +{ + LOG(INFO) << "Scanning all writable path-backed maps for _Py_DebugOffsets"; + for (auto& map : d_memory_maps) { + if (map.Flags().find("w") != std::string::npos && !map.Path().empty()) { + LOG(DEBUG) << std::hex << std::showbase << "Attempting to locate _Py_DebugOffsets in map of " + << map.Path() << " starting at " << map.Start() << " and ending at " << map.End(); + LOG(DEBUG) << "Flags: " << map.Flags(); + try { + if (remote_addr_t result = scanMemoryAreaForDebugOffsets(map)) { + return result; + } + } catch (RemoteMemCopyError& ex) { + LOG(INFO) << "Failed to scan map starting at " << map.Start(); + } + } + } + return 0; +} + ssize_t AbstractProcessManager::copyMemoryFromProcess(remote_addr_t addr, size_t size, void* destination) const { @@ -411,43 +550,40 @@ std::string AbstractProcessManager::getStringFromAddress(remote_addr_t addr) const { Python2::_PyStringObject string; - Python3::PyUnicodeObject unicode; std::vector buffer; ssize_t len; remote_addr_t data_addr; if (d_major == 2) { - LOG(DEBUG) << std::hex << std::showbase << "Handling unicode object of version 2 from address " + LOG(DEBUG) << std::hex << std::showbase << "Handling string object of version 2 from address " << addr; copyObjectFromProcess(addr, &string); len = string.ob_base.ob_size; buffer.resize(len); data_addr = (remote_addr_t)((char*)addr + offsetof(Python2::_PyStringObject, ob_sval)); - LOG(DEBUG) << std::hex << std::showbase << "Copying ASCII data for unicode object from address " + LOG(DEBUG) << std::hex << std::showbase << "Copying ASCII data for string object from address " << data_addr; copyMemoryFromProcess(data_addr, len, buffer.data()); } else { LOG(DEBUG) << std::hex << std::showbase << "Handling unicode object of version 3 from address " << addr; - copyObjectFromProcess(addr, &unicode); - if (unicode._base._base.state.kind != 1u) { - throw InvalidRemoteObject(); - } - if (unicode._base._base.state.compact != 1u) { - throw InvalidRemoteObject(); - } - len = unicode._base._base.length; - buffer.resize(len); + Structure unicode(shared_from_this(), addr); - size_t offset; - if (d_major > 3 || (d_major == 3 && d_minor >= 12)) { - offset = sizeof(Python3_12::PyASCIIObject); + AnyPyUnicodeState state = unicode.getField(&py_unicode_v::o_state); + if (versionIsAtLeast(3, 14) and isFreeThreaded()) { + if (state.python3_14t.kind != 1 || state.python3_14t.compact != 1) { + throw InvalidRemoteObject(); + } } else { - offset = sizeof(Python3::PyASCIIObject); + if (state.python3.kind != 1 || state.python3.compact != 1) { + throw InvalidRemoteObject(); + } } - data_addr = ((remote_addr_t)((char*)addr + offset)); + len = unicode.getField(&py_unicode_v::o_length); + buffer.resize(len); + data_addr = unicode.getFieldRemoteAddress(&py_unicode_v::o_ascii); LOG(DEBUG) << std::hex << std::showbase << "Copying ASCII data for unicode object from address " << data_addr; copyMemoryFromProcess(data_addr, len, buffer.data()); @@ -477,15 +613,14 @@ AbstractProcessManager::getBytesFromAddress(remote_addr_t addr) const } else { LOG(DEBUG) << std::hex << std::showbase << "Handling bytes object of version 3 from address " << addr; - PyBytesObject bytes; - - copyObjectFromProcess(addr, &bytes); - - if ((len = bytes.ob_base.ob_size + 1) < 1) { - throw std::runtime_error("Incorrect size of the fetches bytes object"); + Structure bytes(shared_from_this(), addr); + len = bytes.getField(&py_bytes_v::o_ob_size) + 1; + if (len < 1) { + throw std::runtime_error("Incorrect size of the fetched bytes object"); } buffer.resize(len); - data_addr = (remote_addr_t)((char*)addr + offsetof(PyBytesObject, ob_sval)); + data_addr = bytes.getFieldRemoteAddress(&py_bytes_v::o_ob_sval); + LOG(DEBUG) << std::hex << std::showbase << "Copying data for bytes object from address " << data_addr; copyMemoryFromProcess(data_addr, len, buffer.data()); @@ -535,6 +670,19 @@ AbstractProcessManager::Pid() const { return d_pid; } + +remote_addr_t +AbstractProcessManager::getAddressFromCache(const std::string& symbol) const +{ + return d_type_cache[symbol]; +} + +void +AbstractProcessManager::registerAddressInCache(const std::string& symbol, remote_addr_t address) const +{ + d_type_cache[symbol] = address; +} + std::string AbstractProcessManager::getCStringFromAddress(remote_addr_t addr) const { @@ -553,27 +701,110 @@ AbstractProcessManager::isInterpreterActive() const { remote_addr_t runtime_addr = findSymbol("_PyRuntime"); if (runtime_addr) { - PyRuntimeState py_runtime; - copyObjectFromProcess(runtime_addr, &py_runtime); - remote_addr_t p = getField(py_runtime, &py_runtime_v::o_finalizing); + Structure py_runtime(shared_from_this(), runtime_addr); + remote_addr_t p = py_runtime.getField(&py_runtime_v::o_finalizing); return p == 0 ? InterpreterStatus::RUNNING : InterpreterStatus::FINALIZED; } return InterpreterStatus::UNKNOWN; } +void +AbstractProcessManager::setPythonVersionFromDebugOffsets() +{ + remote_addr_t pyruntime_addr = findSymbol("_PyRuntime"); + if (!pyruntime_addr) { + pyruntime_addr = findPyRuntimeFromElfData(); + } + if (!pyruntime_addr) { + pyruntime_addr = findDebugOffsetsFromMaps(); + } + + if (!pyruntime_addr) { + LOG(DEBUG) << "Unable to find _Py_DebugOffsets"; + return; + } + + try { + uint64_t cookie; + copyObjectFromProcess(pyruntime_addr, &cookie); + if (0 != memcmp(&cookie, "xdebugpy", 8)) { + LOG(DEBUG) << "Found a _PyRuntime structure without _Py_DebugOffsets"; + return; + } + + uint64_t version; + copyObjectFromProcess(pyruntime_addr + 8, &version); + + ParsedPyVersion parsed; + if (parsePyVersionHex(version, parsed) && parsed.major == 3 && parsed.minor >= 13) { + LOG(INFO) << std::hex << std::showbase << "_Py_DebugOffsets at " << pyruntime_addr + << " identify the version as " << parsed; + setPythonVersion(std::make_pair(parsed.major, parsed.minor)); + Structure py_runtime(shared_from_this(), pyruntime_addr); + bool is_free_threaded = py_runtime.getField(&py_runtime_v::o_dbg_off_free_threaded); + std::unique_ptr offsets = loadDebugOffsets(py_runtime); + if (offsets) { + LOG(INFO) << "_Py_DebugOffsets appear to be valid and will be used"; + warnIfOffsetsAreMismatched(pyruntime_addr); + d_debug_offsets_addr = pyruntime_addr; + d_debug_offsets = std::move(offsets); + d_is_free_threaded = is_free_threaded; + return; + } + } + } catch (const RemoteMemCopyError& ex) { + LOG(DEBUG) << std::hex << std::showbase << "Found apparently invalid _Py_DebugOffsets at " + << pyruntime_addr; + } + + LOG(DEBUG) << "Failed to validate _PyDebugOffsets structure"; + d_major = 0; + d_minor = 0; + d_py_v = nullptr; + d_debug_offsets_addr = 0; + d_debug_offsets.reset(); +} + std::pair AbstractProcessManager::findPythonVersion() const { + if (d_py_v) { + // Already set or previously found (probably via _Py_DebugOffsets) + return std::make_pair(d_major, d_minor); + } + auto version_symbol = findSymbol("Py_Version"); if (!version_symbol) { - LOG(DEBUG) << "Faled to determine Python version from symbols"; + LOG(DEBUG) << "Failed to determine Python version from symbols"; return {-1, -1}; } unsigned long version; - copyObjectFromProcess(version_symbol, &version); + try { + copyObjectFromProcess(version_symbol, &version); + } catch (RemoteMemCopyError& ex) { + LOG(DEBUG) << "Failed to determine Python version from symbols"; + return {-1, -1}; + } int major = (version >> 24) & 0xFF; int minor = (version >> 16) & 0xFF; + int level = (version >> 4) & 0x0F; + + if (major == 0 && minor == 0) { + LOG(DEBUG) << "Failed to determine Python version from symbols: empty data copied"; + return {-1, -1}; + } + + if (major != 2 && major != 3) { + LOG(DEBUG) << "Failed to determine Python version from symbols: invalid major version"; + return {-1, -1}; + } + + if (level != 0xA && level != 0xB && level != 0xC && level != 0xF) { + LOG(DEBUG) << "Failed to determine Python version from symbols: invalid release level"; + return {-1, -1}; + } + LOG(DEBUG) << "Python version determined from symbols: " << major << "." << minor; return {major, minor}; } @@ -587,20 +818,528 @@ AbstractProcessManager::setPythonVersion(const std::pair& version) d_minor = version.second; } +void +AbstractProcessManager::warnIfOffsetsAreMismatched(remote_addr_t runtime_addr) const +{ + if (versionIsAtLeast(3, 14)) { + // From 3.14 onwards, we don't have static offsets to compare to. + return; + } + + Structure py_runtime(shared_from_this(), runtime_addr); + + if (0 != memcmp(py_runtime.getField(&py_runtime_v::o_dbg_off_cookie), "xdebugpy", 8)) { + LOG(WARNING) << "Debug offsets cookie doesn't match!"; + return; + } + + // Note: It's OK for pystack's size to be smaller, but not larger. +#define compare_size(size_offset, pystack_struct) \ + if ((d_py_v->py_runtime.*size_offset).offset \ + && ((uint64_t)offsets().pystack_struct.size > py_runtime.getField(size_offset))) \ + { \ + LOG(INFO) << "Debug offsets mismatch: compiled-in " << sizeof(void*) * 8 << "-bit python3." \ + << d_minor << " " #pystack_struct ".size " << offsets().pystack_struct.size << " > " \ + << py_runtime.getField(size_offset) << " loaded from _Py_DebugOffsets"; \ + } else \ + do { \ + } while (0) + +#define compare_offset(field_offset_offset, pystack_field) \ + if ((d_py_v->py_runtime.*field_offset_offset).offset \ + && (uint64_t)offsets().pystack_field.offset != py_runtime.getField(field_offset_offset)) \ + { \ + LOG(INFO) << "Debug offsets mismatch: compiled-in " << sizeof(void*) * 8 << "-bit python3." \ + << d_minor << " " #pystack_field << " " << offsets().pystack_field.offset \ + << " != " << py_runtime.getField(field_offset_offset) \ + << " loaded from _Py_DebugOffsets"; \ + } else \ + do { \ + } while (0) + + compare_size(&py_runtime_v::o_dbg_off_runtime_state_struct_size, py_runtime); + compare_offset(&py_runtime_v::o_dbg_off_runtime_state_finalizing, py_runtime.o_finalizing); + compare_offset(&py_runtime_v::o_dbg_off_runtime_state_interpreters_head, py_runtime.o_interp_head); + + compare_size(&py_runtime_v::o_dbg_off_interpreter_state_struct_size, py_is); + compare_offset(&py_runtime_v::o_dbg_off_interpreter_state_next, py_is.o_next); + compare_offset(&py_runtime_v::o_dbg_off_interpreter_state_threads_head, py_is.o_tstate_head); + compare_offset(&py_runtime_v::o_dbg_off_interpreter_state_gc, py_is.o_gc); + compare_offset(&py_runtime_v::o_dbg_off_interpreter_state_imports_modules, py_is.o_modules); + compare_offset(&py_runtime_v::o_dbg_off_interpreter_state_sysdict, py_is.o_sysdict); + compare_offset(&py_runtime_v::o_dbg_off_interpreter_state_builtins, py_is.o_builtins); + compare_offset(&py_runtime_v::o_dbg_off_interpreter_state_ceval_gil, py_is.o_gil_runtime_state); + + compare_size(&py_runtime_v::o_dbg_off_thread_state_struct_size, py_thread); + compare_offset(&py_runtime_v::o_dbg_off_thread_state_prev, py_thread.o_prev); + compare_offset(&py_runtime_v::o_dbg_off_thread_state_next, py_thread.o_next); + compare_offset(&py_runtime_v::o_dbg_off_thread_state_interp, py_thread.o_interp); + compare_offset(&py_runtime_v::o_dbg_off_thread_state_current_frame, py_thread.o_frame); + compare_offset(&py_runtime_v::o_dbg_off_thread_state_thread_id, py_thread.o_thread_id); + compare_offset(&py_runtime_v::o_dbg_off_thread_state_native_thread_id, py_thread.o_native_thread_id); + + compare_size(&py_runtime_v::o_dbg_off_interpreter_frame_struct_size, py_frame); + compare_offset(&py_runtime_v::o_dbg_off_interpreter_frame_previous, py_frame.o_back); + compare_offset(&py_runtime_v::o_dbg_off_interpreter_frame_executable, py_frame.o_code); + compare_offset(&py_runtime_v::o_dbg_off_interpreter_frame_instr_ptr, py_frame.o_prev_instr); + compare_offset(&py_runtime_v::o_dbg_off_interpreter_frame_localsplus, py_frame.o_localsplus); + compare_offset(&py_runtime_v::o_dbg_off_interpreter_frame_owner, py_frame.o_owner); + + compare_size(&py_runtime_v::o_dbg_off_code_object_struct_size, py_code); + compare_offset(&py_runtime_v::o_dbg_off_code_object_filename, py_code.o_filename); + compare_offset(&py_runtime_v::o_dbg_off_code_object_name, py_code.o_name); + compare_offset(&py_runtime_v::o_dbg_off_code_object_linetable, py_code.o_lnotab); + compare_offset(&py_runtime_v::o_dbg_off_code_object_firstlineno, py_code.o_firstlineno); + compare_offset(&py_runtime_v::o_dbg_off_code_object_argcount, py_code.o_argcount); + compare_offset(&py_runtime_v::o_dbg_off_code_object_localsplusnames, py_code.o_varnames); + compare_offset(&py_runtime_v::o_dbg_off_code_object_co_code_adaptive, py_code.o_code_adaptive); + + compare_size(&py_runtime_v::o_dbg_off_pyobject_struct_size, py_object); + compare_offset(&py_runtime_v::o_dbg_off_pyobject_ob_type, py_object.o_ob_type); + + compare_size(&py_runtime_v::o_dbg_off_type_object_struct_size, py_type); + compare_offset(&py_runtime_v::o_dbg_off_type_object_tp_name, py_type.o_tp_name); + compare_offset(&py_runtime_v::o_dbg_off_type_object_tp_repr, py_type.o_tp_repr); + compare_offset(&py_runtime_v::o_dbg_off_type_object_tp_flags, py_type.o_tp_flags); + + compare_size(&py_runtime_v::o_dbg_off_tuple_object_struct_size, py_tuple); + compare_offset(&py_runtime_v::o_dbg_off_tuple_object_ob_item, py_tuple.o_ob_item); + compare_offset(&py_runtime_v::o_dbg_off_tuple_object_ob_size, py_tuple.o_ob_size); + + compare_size(&py_runtime_v::o_dbg_off_list_object_struct_size, py_list); + compare_offset(&py_runtime_v::o_dbg_off_list_object_ob_item, py_list.o_ob_item); + compare_offset(&py_runtime_v::o_dbg_off_list_object_ob_size, py_list.o_ob_size); + + compare_size(&py_runtime_v::o_dbg_off_dict_object_struct_size, py_dict); + compare_offset(&py_runtime_v::o_dbg_off_dict_object_ma_keys, py_dict.o_ma_keys); + compare_offset(&py_runtime_v::o_dbg_off_dict_object_ma_values, py_dict.o_ma_values); + + compare_size(&py_runtime_v::o_dbg_off_float_object_struct_size, py_float); + compare_offset(&py_runtime_v::o_dbg_off_float_object_ob_fval, py_float.o_ob_fval); + + compare_size(&py_runtime_v::o_dbg_off_long_object_struct_size, py_long); + compare_offset(&py_runtime_v::o_dbg_off_long_object_lv_tag, py_long.o_ob_size); + compare_offset(&py_runtime_v::o_dbg_off_long_object_ob_digit, py_long.o_ob_digit); + + compare_size(&py_runtime_v::o_dbg_off_bytes_object_struct_size, py_bytes); + compare_offset(&py_runtime_v::o_dbg_off_bytes_object_ob_size, py_bytes.o_ob_size); + compare_offset(&py_runtime_v::o_dbg_off_bytes_object_ob_sval, py_bytes.o_ob_sval); + + compare_size(&py_runtime_v::o_dbg_off_unicode_object_struct_size, py_unicode); + compare_offset(&py_runtime_v::o_dbg_off_unicode_object_state, py_unicode.o_state); + compare_offset(&py_runtime_v::o_dbg_off_unicode_object_length, py_unicode.o_length); + compare_offset(&py_runtime_v::o_dbg_off_unicode_object_asciiobject_size, py_unicode.o_ascii); + + compare_size(&py_runtime_v::o_dbg_off_gc_struct_size, py_gc); + compare_offset(&py_runtime_v::o_dbg_off_gc_collecting, py_gc.o_collecting); + +#undef compare_size +#undef compare_offset +} + +std::unique_ptr +AbstractProcessManager::loadDebugOffsets(Structure& py_runtime) const +{ + if (!versionIsAtLeast(3, 13)) { + return {}; // _Py_DebugOffsets was added in 3.13 + } + + if (0 != memcmp(py_runtime.getField(&py_runtime_v::o_dbg_off_cookie), "xdebugpy", 8)) { + LOG(WARNING) << "Debug offsets cookie doesn't match!"; + return {}; + } + + uint64_t version = py_runtime.getField(&py_runtime_v::o_dbg_off_py_version_hex); + int major = (version >> 24) & 0xff; + int minor = (version >> 16) & 0xff; + + if (major != d_major || minor != d_minor) { + LOG(WARNING) << "Detected version " << d_major << "." << d_minor + << " doesn't match debug offsets version " << major << "." << minor << "!"; + return {}; + } + + python_v debug_offsets{}; + if (!copyDebugOffsets(py_runtime, debug_offsets)) { + return {}; + } + + if (!validateDebugOffsets(py_runtime, debug_offsets)) { + return {}; + } + + auto ret = std::make_unique(); + *ret = debug_offsets; + clampSizes(*ret); + return ret; +} + +bool +AbstractProcessManager::copyDebugOffsets(Structure& py_runtime, python_v& debug_offsets) + const +{ + // Fill in a temporary python_v with the offsets from the remote. For fields + // that aren't in _Py_DebugOffsets, assume our static offsets are correct. + +#define set_size(pystack_struct, size_offset) \ + debug_offsets.pystack_struct.size = py_runtime.getField(size_offset) + +#define set_offset(pystack_field, field_offset_offset) \ + debug_offsets.pystack_field = {(offset_t)py_runtime.getField(field_offset_offset)} + + set_size(py_runtime, &py_runtime_v::o_dbg_off_runtime_state_struct_size); + set_offset(py_runtime.o_finalizing, &py_runtime_v::o_dbg_off_runtime_state_finalizing); + set_offset(py_runtime.o_interp_head, &py_runtime_v::o_dbg_off_runtime_state_interpreters_head); + + set_size(py_is, &py_runtime_v::o_dbg_off_interpreter_state_struct_size); + set_offset(py_is.o_next, &py_runtime_v::o_dbg_off_interpreter_state_next); + set_offset(py_is.o_tstate_head, &py_runtime_v::o_dbg_off_interpreter_state_threads_head); + set_offset(py_is.o_gc, &py_runtime_v::o_dbg_off_interpreter_state_gc); + set_offset(py_is.o_modules, &py_runtime_v::o_dbg_off_interpreter_state_imports_modules); + set_offset(py_is.o_sysdict, &py_runtime_v::o_dbg_off_interpreter_state_sysdict); + set_offset(py_is.o_builtins, &py_runtime_v::o_dbg_off_interpreter_state_builtins); + set_offset(py_is.o_gil_runtime_state, &py_runtime_v::o_dbg_off_interpreter_state_ceval_gil); + set_offset(py_is.o_id, &py_runtime_v::o_dbg_off_interpreter_state_id); + + set_size(py_thread, &py_runtime_v::o_dbg_off_thread_state_struct_size); + set_offset(py_thread.o_prev, &py_runtime_v::o_dbg_off_thread_state_prev); + set_offset(py_thread.o_next, &py_runtime_v::o_dbg_off_thread_state_next); + set_offset(py_thread.o_interp, &py_runtime_v::o_dbg_off_thread_state_interp); + set_offset(py_thread.o_frame, &py_runtime_v::o_dbg_off_thread_state_current_frame); + set_offset(py_thread.o_thread_id, &py_runtime_v::o_dbg_off_thread_state_thread_id); + set_offset(py_thread.o_native_thread_id, &py_runtime_v::o_dbg_off_thread_state_native_thread_id); + + set_size(py_frame, &py_runtime_v::o_dbg_off_interpreter_frame_struct_size); + set_offset(py_frame.o_back, &py_runtime_v::o_dbg_off_interpreter_frame_previous); + set_offset(py_frame.o_code, &py_runtime_v::o_dbg_off_interpreter_frame_executable); + set_offset(py_frame.o_prev_instr, &py_runtime_v::o_dbg_off_interpreter_frame_instr_ptr); + set_offset(py_frame.o_localsplus, &py_runtime_v::o_dbg_off_interpreter_frame_localsplus); + set_offset(py_frame.o_owner, &py_runtime_v::o_dbg_off_interpreter_frame_owner); + + set_size(py_code, &py_runtime_v::o_dbg_off_code_object_struct_size); + set_offset(py_code.o_filename, &py_runtime_v::o_dbg_off_code_object_filename); + set_offset(py_code.o_name, &py_runtime_v::o_dbg_off_code_object_name); + set_offset(py_code.o_lnotab, &py_runtime_v::o_dbg_off_code_object_linetable); + set_offset(py_code.o_firstlineno, &py_runtime_v::o_dbg_off_code_object_firstlineno); + set_offset(py_code.o_argcount, &py_runtime_v::o_dbg_off_code_object_argcount); + set_offset(py_code.o_varnames, &py_runtime_v::o_dbg_off_code_object_localsplusnames); + set_offset(py_code.o_code_adaptive, &py_runtime_v::o_dbg_off_code_object_co_code_adaptive); + + set_size(py_object, &py_runtime_v::o_dbg_off_pyobject_struct_size); + set_offset(py_object.o_ob_type, &py_runtime_v::o_dbg_off_pyobject_ob_type); + + set_size(py_type, &py_runtime_v::o_dbg_off_type_object_struct_size); + set_offset(py_type.o_tp_name, &py_runtime_v::o_dbg_off_type_object_tp_name); + set_offset(py_type.o_tp_repr, &py_runtime_v::o_dbg_off_type_object_tp_repr); + set_offset(py_type.o_tp_flags, &py_runtime_v::o_dbg_off_type_object_tp_flags); + + set_size(py_tuple, &py_runtime_v::o_dbg_off_tuple_object_struct_size); + set_offset(py_tuple.o_ob_item, &py_runtime_v::o_dbg_off_tuple_object_ob_item); + set_offset(py_tuple.o_ob_size, &py_runtime_v::o_dbg_off_tuple_object_ob_size); + + set_size(py_list, &py_runtime_v::o_dbg_off_list_object_struct_size); + set_offset(py_list.o_ob_item, &py_runtime_v::o_dbg_off_list_object_ob_item); + set_offset(py_list.o_ob_size, &py_runtime_v::o_dbg_off_list_object_ob_size); + + set_size(py_dict, &py_runtime_v::o_dbg_off_dict_object_struct_size); + set_offset(py_dict.o_ma_keys, &py_runtime_v::o_dbg_off_dict_object_ma_keys); + set_offset(py_dict.o_ma_values, &py_runtime_v::o_dbg_off_dict_object_ma_values); + + // Assume our static offsets for dict keys and values are correct + debug_offsets.py_dictkeys = d_py_v->py_dictkeys; + debug_offsets.py_dictvalues = d_py_v->py_dictvalues; + + set_size(py_float, &py_runtime_v::o_dbg_off_float_object_struct_size); + set_offset(py_float.o_ob_fval, &py_runtime_v::o_dbg_off_float_object_ob_fval); + + set_size(py_long, &py_runtime_v::o_dbg_off_long_object_struct_size); + set_offset(py_long.o_ob_size, &py_runtime_v::o_dbg_off_long_object_lv_tag); + set_offset(py_long.o_ob_digit, &py_runtime_v::o_dbg_off_long_object_ob_digit); + + set_size(py_bytes, &py_runtime_v::o_dbg_off_bytes_object_struct_size); + set_offset(py_bytes.o_ob_size, &py_runtime_v::o_dbg_off_bytes_object_ob_size); + set_offset(py_bytes.o_ob_sval, &py_runtime_v::o_dbg_off_bytes_object_ob_sval); + + set_size(py_unicode, &py_runtime_v::o_dbg_off_unicode_object_struct_size); + set_offset(py_unicode.o_state, &py_runtime_v::o_dbg_off_unicode_object_state); + set_offset(py_unicode.o_length, &py_runtime_v::o_dbg_off_unicode_object_length); + set_offset(py_unicode.o_ascii, &py_runtime_v::o_dbg_off_unicode_object_asciiobject_size); + + set_size(py_gc, &py_runtime_v::o_dbg_off_gc_struct_size); + set_offset(py_gc.o_collecting, &py_runtime_v::o_dbg_off_gc_collecting); + + // Assume our static offsets for cframe are all correct + debug_offsets.py_cframe = d_py_v->py_cframe; + + size_t gilruntimestate_start = + py_runtime.getField(&py_runtime_v::o_dbg_off_interpreter_state_gil_runtime_state); + + size_t gilruntimestate_locked_offset = + py_runtime.getField(&py_runtime_v::o_dbg_off_interpreter_state_gil_runtime_state_locked) + - gilruntimestate_start; + size_t gilruntimestate_holder_offset = + py_runtime.getField(&py_runtime_v::o_dbg_off_interpreter_state_gil_runtime_state_holder) + - gilruntimestate_start; + + debug_offsets.py_gilruntimestate.size = std::max( + {gilruntimestate_locked_offset + sizeof(decltype(py_gilruntimestate_v::o_locked)::Type), + gilruntimestate_holder_offset + + sizeof(decltype(py_gilruntimestate_v::o_last_holder)::Type)}); + debug_offsets.py_gilruntimestate.o_locked.offset = gilruntimestate_locked_offset; + debug_offsets.py_gilruntimestate.o_last_holder.offset = gilruntimestate_holder_offset; + +#undef set_size +#undef set_offset + + return true; +} + +bool +AbstractProcessManager::validateDebugOffsets( + const Structure& py_runtime, + python_v& debug_offsets) const +{ + // Simple sanity checks on the decoded offsets: + // - No structure is larger than 1 MB + // - Every field falls within its structure's size +#define check_size(pystack_struct, size_offset) \ + do { \ + if (debug_offsets.pystack_struct.size > 1024 * 1024) { \ + LOG(WARNING) << "Ignoring debug offsets because " #pystack_struct ".size (" \ + << debug_offsets.pystack_struct.size << ") reported at byte offset " \ + << (d_py_v->py_runtime.*size_offset).offset \ + << " in detected _Py_DebugOffsets structure at " << std::hex << std::showbase \ + << py_runtime.getFieldRemoteAddress(&py_runtime_v::o_dbg_off_cookie) \ + << " is implausibly large"; \ + return {}; \ + } \ + } while (0) + +#define check_field_bounds(structure, field) \ + do { \ + if (debug_offsets.structure.size < 0 \ + || (size_t)debug_offsets.structure.size < debug_offsets.structure.field.offset \ + || debug_offsets.structure.size - debug_offsets.structure.field.offset \ + < sizeof(decltype(debug_offsets.structure.field)::Type)) \ + { \ + LOG(WARNING) << "Ignoring debug offsets because " #structure ".size (" \ + << debug_offsets.structure.size << ") - " #structure "." #field ".offset (" \ + << debug_offsets.structure.field.offset << ") < the field's size (" \ + << sizeof(decltype(debug_offsets.structure.field)::Type) << ")"; \ + return {}; \ + } \ + } while (0) + + check_size(py_runtime, &py_runtime_v::o_dbg_off_runtime_state_struct_size); + check_field_bounds(py_runtime, o_finalizing); + check_field_bounds(py_runtime, o_interp_head); + + check_size(py_is, &py_runtime_v::o_dbg_off_interpreter_state_struct_size); + check_field_bounds(py_is, o_next); + check_field_bounds(py_is, o_tstate_head); + check_field_bounds(py_is, o_gc); + check_field_bounds(py_is, o_modules); + check_field_bounds(py_is, o_sysdict); + check_field_bounds(py_is, o_builtins); + check_field_bounds(py_is, o_gil_runtime_state); + + check_size(py_thread, &py_runtime_v::o_dbg_off_thread_state_struct_size); + check_field_bounds(py_thread, o_prev); + check_field_bounds(py_thread, o_next); + check_field_bounds(py_thread, o_interp); + check_field_bounds(py_thread, o_frame); + check_field_bounds(py_thread, o_thread_id); + check_field_bounds(py_thread, o_native_thread_id); + + check_size(py_frame, &py_runtime_v::o_dbg_off_interpreter_frame_struct_size); + check_field_bounds(py_frame, o_back); + check_field_bounds(py_frame, o_code); + check_field_bounds(py_frame, o_prev_instr); + check_field_bounds(py_frame, o_localsplus); + check_field_bounds(py_frame, o_owner); + + check_size(py_code, &py_runtime_v::o_dbg_off_code_object_struct_size); + check_field_bounds(py_code, o_filename); + check_field_bounds(py_code, o_name); + check_field_bounds(py_code, o_lnotab); + check_field_bounds(py_code, o_firstlineno); + check_field_bounds(py_code, o_argcount); + check_field_bounds(py_code, o_varnames); + check_field_bounds(py_code, o_code_adaptive); + + check_size(py_object, &py_runtime_v::o_dbg_off_pyobject_struct_size); + check_field_bounds(py_object, o_ob_type); + + check_size(py_type, &py_runtime_v::o_dbg_off_type_object_struct_size); + check_field_bounds(py_type, o_tp_name); + check_field_bounds(py_type, o_tp_repr); + check_field_bounds(py_type, o_tp_flags); + + check_size(py_tuple, &py_runtime_v::o_dbg_off_tuple_object_struct_size); + check_field_bounds(py_tuple, o_ob_size); + check_field_bounds(py_tuple, o_ob_item); + + check_size(py_unicode, &py_runtime_v::o_dbg_off_unicode_object_struct_size); + check_field_bounds(py_unicode, o_state); + check_field_bounds(py_unicode, o_length); + check_field_bounds(py_unicode, o_ascii); + + check_size(py_gc, &py_runtime_v::o_dbg_off_gc_struct_size); + check_field_bounds(py_gc, o_collecting); + + check_field_bounds(py_list, o_ob_size); + check_field_bounds(py_list, o_ob_item); + + check_field_bounds(py_dictkeys, o_dk_size); + check_field_bounds(py_dictkeys, o_dk_kind); + check_field_bounds(py_dictkeys, o_dk_nentries); + check_field_bounds(py_dictkeys, o_dk_indices); + + check_field_bounds(py_dictvalues, o_values); + + check_field_bounds(py_dict, o_ma_keys); + check_field_bounds(py_dict, o_ma_values); + + check_field_bounds(py_float, o_ob_fval); + + check_field_bounds(py_long, o_ob_size); + check_field_bounds(py_long, o_ob_digit); + + check_field_bounds(py_bytes, o_ob_size); + check_field_bounds(py_bytes, o_ob_sval); + + check_field_bounds(py_cframe, current_frame); + +#undef check_size +#undef check_field_bounds + + return true; +} + +void +AbstractProcessManager::clampSizes(python_v& debug_offsets) const +{ + // Clamp the size of each struct down to only what we need to copy. + // The runtime state and interpreter state both contain many fields beyond + // the ones that we're interested in or have offsets for. +#define update_size(structure, field) \ + debug_offsets.structure.size = std::max( \ + (size_t)debug_offsets.structure.size, \ + debug_offsets.structure.field.offset \ + + sizeof(decltype(debug_offsets.structure.field)::Type)) + + debug_offsets.py_runtime.size = 0; + update_size(py_runtime, o_finalizing); + update_size(py_runtime, o_interp_head); + + debug_offsets.py_is.size = 0; + update_size(py_is, o_next); + update_size(py_is, o_tstate_head); + update_size(py_is, o_gc); + update_size(py_is, o_modules); + update_size(py_is, o_sysdict); + update_size(py_is, o_builtins); + update_size(py_is, o_gil_runtime_state); + + debug_offsets.py_thread.size = 0; + update_size(py_thread, o_prev); + update_size(py_thread, o_next); + update_size(py_thread, o_interp); + update_size(py_thread, o_frame); + update_size(py_thread, o_thread_id); + update_size(py_thread, o_native_thread_id); + + debug_offsets.py_frame.size = 0; + update_size(py_frame, o_back); + update_size(py_frame, o_code); + update_size(py_frame, o_prev_instr); + update_size(py_frame, o_localsplus); + update_size(py_frame, o_owner); + + debug_offsets.py_code.size = 0; + update_size(py_code, o_filename); + update_size(py_code, o_name); + update_size(py_code, o_lnotab); + update_size(py_code, o_firstlineno); + update_size(py_code, o_argcount); + update_size(py_code, o_varnames); + update_size(py_code, o_code_adaptive); + + debug_offsets.py_object.size = 0; + update_size(py_object, o_ob_type); + + debug_offsets.py_type.size = 0; + update_size(py_type, o_tp_name); + update_size(py_type, o_tp_repr); + update_size(py_type, o_tp_flags); + + debug_offsets.py_tuple.size = 0; + update_size(py_tuple, o_ob_size); + update_size(py_tuple, o_ob_item); + + debug_offsets.py_unicode.size = 0; + update_size(py_unicode, o_state); + update_size(py_unicode, o_length); + update_size(py_unicode, o_ascii); + + debug_offsets.py_gc.size = 0; + update_size(py_gc, o_collecting); + + debug_offsets.py_list.size = 0; + update_size(py_list, o_ob_size); + update_size(py_list, o_ob_item); + + debug_offsets.py_dictkeys.size = 0; + update_size(py_dictkeys, o_dk_size); + update_size(py_dictkeys, o_dk_kind); + update_size(py_dictkeys, o_dk_nentries); + update_size(py_dictkeys, o_dk_indices); + + debug_offsets.py_dictvalues.size = 0; + update_size(py_dictvalues, o_values); + + debug_offsets.py_dict.size = 0; + update_size(py_dict, o_ma_keys); + update_size(py_dict, o_ma_values); + + debug_offsets.py_float.size = 0; + update_size(py_float, o_ob_fval); + + debug_offsets.py_long.size = 0; + update_size(py_long, o_ob_size); + update_size(py_long, o_ob_digit); + + debug_offsets.py_bytes.size = 0; + update_size(py_bytes, o_ob_size); + update_size(py_bytes, o_ob_sval); + + debug_offsets.py_cframe.size = 0; + update_size(py_cframe, current_frame); +} + bool AbstractProcessManager::versionIsAtLeast(int required_major, int required_minor) const { return d_major > required_major || (d_major == required_major && d_minor >= required_minor); } +bool +AbstractProcessManager::isFreeThreaded() const +{ + return d_is_free_threaded; +} + const python_v& AbstractProcessManager::offsets() const { + if (d_debug_offsets) { + return *d_debug_offsets; + } return *d_py_v; } remote_addr_t -AbstractProcessManager::findInterpreterStateFromElfData() const +AbstractProcessManager::findPyRuntimeFromElfData() const { LOG(INFO) << "Trying to resolve PyInterpreterState from Elf data"; SectionInfo section_info; @@ -615,7 +1354,72 @@ AbstractProcessManager::findInterpreterStateFromElfData() const "could not be found"; return 0; } - return findInterpreterStateFromPyRuntime(load_addr + section_info.corrected_addr); + return load_addr + section_info.corrected_addr; +} + +remote_addr_t +AbstractProcessManager::findInterpreterStateFromElfData() const +{ + remote_addr_t pyruntime = findPyRuntimeFromElfData(); + if (!pyruntime) { + return 0; + } + return findInterpreterStateFromPyRuntime(pyruntime); +} + +remote_addr_t +AbstractProcessManager::findInterpreterStateFromDebugOffsets() const +{ + if (!d_debug_offsets_addr) { + LOG(DEBUG) << "Debug offsets were never found"; + return 0; + } + + LOG(INFO) << "Searching for PyInterpreterState based on PyRuntime address " << std::hex + << std::showbase << d_debug_offsets_addr + << " found when searching for 3.13+ debug offsets"; + + try { + Structure runtime(shared_from_this(), d_debug_offsets_addr); + remote_addr_t interp_state = runtime.getField(&py_runtime_v::o_interp_head); + LOG(DEBUG) << "Checking interpreter state at " << std::hex << std::showbase << interp_state + << " found at address " + << runtime.getFieldRemoteAddress(&py_runtime_v::o_interp_head); + if (isValidInterpreterState(interp_state)) { + LOG(DEBUG) << "Interpreter head reference from debug offsets dereferences successfully"; + return interp_state; + } + } catch (...) { + // Swallow exceptions and fall through to return failure + } + LOG(INFO) << "Failed to resolve PyInterpreterState based on PyRuntime address " << std::hex + << std::showbase << d_debug_offsets_addr; + return 0; +} + +std::shared_ptr +ProcessManager::create(pid_t pid, bool stop_process) +{ + std::shared_ptr tracer; + if (stop_process) { + tracer = std::make_shared(pid); + } + + auto virtual_maps = parseProcMaps(pid); + auto map_info = parseMapInformationForProcess(pid, virtual_maps); + auto analyzer = std::make_shared(pid); + + auto manager = std::make_shared( + pid, + tracer, + analyzer, + std::move(virtual_maps), + getMainMap(map_info), + map_info.bss, + map_info.heap); + + manager->initializeVersion(pid, map_info); + return manager; } ProcessManager::ProcessManager( @@ -623,12 +1427,19 @@ ProcessManager::ProcessManager( const std::shared_ptr& tracer, const std::shared_ptr& analyzer, std::vector memory_maps, - MemoryMapInformation map_info) -: AbstractProcessManager(pid, std::move(memory_maps), std::move(map_info)) -, tracer(tracer) + std::optional main_map, + std::optional bss, + std::optional heap) +: AbstractProcessManager( + pid, + std::move(memory_maps), + std::move(main_map), + std::move(bss), + std::move(heap)) +, d_tracer(tracer) { - if (tracer) { - d_tids = tracer->getTids(); + if (d_tracer) { + d_tids = d_tracer->getTids(); } else { d_tids = getProcessTids(pid); } @@ -637,25 +1448,121 @@ ProcessManager::ProcessManager( d_unwinder = std::make_unique(analyzer); } +void +ProcessManager::initializeVersion(pid_t pid, const ProcessMemoryMapInfo& map_info) +{ + // Try to get version from debug offsets first + setPythonVersionFromDebugOffsets(); + auto python_version = findPythonVersion(); + + // Fallback to external version detection if needed + if (python_version.first == -1 && python_version.second == -1) { + python_version = getVersionForProcess(pid, map_info, d_manager.get()); + } + + setPythonVersion(python_version); + + if (!d_debug_offsets && versionIsAtLeast(3, 14)) { + throw std::runtime_error( + "The process runs Python " + std::to_string(d_major) + "." + std::to_string(d_minor) + + ", but we've failed to locate its _Py_DebugOffsets structure in memory."); + } +} + const std::vector& ProcessManager::Tids() const { return d_tids; } +std::pair +AbstractProcessManager::pythonVersion() const +{ + return {d_major, d_minor}; +} + +std::shared_ptr +CoreFileProcessManager::create( + const std::string& core_file, + const std::string& executable, + const std::optional& lib_search_path) +{ + std::shared_ptr analyzer; + if (lib_search_path) { + analyzer = std::make_shared(core_file, executable, *lib_search_path); + } else { + analyzer = std::make_shared(core_file, executable); + } + + auto extractor = std::make_unique(analyzer); + + auto mapped_files = extractor->extractMappedFiles(); + auto memory_maps = extractor->MemoryMaps(); + + std::unordered_map load_point_by_module; + for (const auto& mod : extractor->ModuleInformation()) { + auto name = fs::path(mod.filename).filename().string(); + load_point_by_module[name] = mod.start; + } + + auto virtual_maps = parseCoreFileMaps(mapped_files, memory_maps); + pid_t pid = extractor->Pid(); + auto map_info = parseMapInformation(executable, virtual_maps, &load_point_by_module); + + auto manager = std::make_shared( + pid, + analyzer, + std::move(virtual_maps), + getMainMap(map_info), + map_info.bss, + map_info.heap); + + manager->initializeVersion(core_file, map_info); + return manager; +} + CoreFileProcessManager::CoreFileProcessManager( pid_t pid, const std::shared_ptr& analyzer, std::vector memory_maps, - MemoryMapInformation map_info) -: AbstractProcessManager(pid, std::move(memory_maps), std::move(map_info)) + std::optional main_map, + std::optional bss, + std::optional heap) +: AbstractProcessManager( + pid, + std::move(memory_maps), + std::move(main_map), + std::move(bss), + std::move(heap)) { d_analyzer = analyzer; d_manager = std::make_unique(analyzer, d_memory_maps); - d_executable = analyzer->d_executable; - std::unique_ptr the_unwinder = std::make_unique(analyzer); - d_tids = the_unwinder->getCoreTids(); - d_unwinder = std::move(the_unwinder); + auto unwinder = std::make_unique(analyzer); + d_tids = unwinder->getCoreTids(); + d_unwinder = std::move(unwinder); +} + +void +CoreFileProcessManager::initializeVersion( + const std::string& core_file, + const ProcessMemoryMapInfo& map_info) +{ + // Try to get version from debug offsets first + setPythonVersionFromDebugOffsets(); + auto python_version = findPythonVersion(); + + // Fallback to external version detection if needed + if (python_version.first == -1 && python_version.second == -1) { + python_version = getVersionForCore(core_file, map_info); + } + + setPythonVersion(python_version); + + if (!d_debug_offsets && versionIsAtLeast(3, 14)) { + throw std::runtime_error( + "The process runs Python " + std::to_string(d_major) + "." + std::to_string(d_minor) + + ", but we've failed to locate its _Py_DebugOffsets structure in memory."); + } } const std::vector& diff --git a/src/pystack/_pystack/process.h b/src/pystack/_pystack/process.h index 086684d7..965a0d4b 100644 --- a/src/pystack/_pystack/process.h +++ b/src/pystack/_pystack/process.h @@ -1,7 +1,5 @@ #pragma once -#include - #include #include #include @@ -12,6 +10,7 @@ #include #include "elf_common.h" +#include "maps_parser.h" #include "mem.h" #include "native_frame.h" #include "pycompat.h" @@ -20,6 +19,9 @@ namespace pystack { +template +class Structure; + struct InvalidRemoteObject : public InvalidCopiedMemory { const char* what() const noexcept override @@ -60,11 +62,17 @@ class AbstractProcessManager : public std::enable_shared_from_this&& memory_maps, - MemoryMapInformation&& map_info); + std::optional main_map, + std::optional bss, + std::optional heap); // Getters pid_t Pid() const; virtual const std::vector& Tids() const = 0; + const std::vector& MemoryMaps() const; + std::pair Version() const; + remote_addr_t getAddressFromCache(const std::string& symbol) const; + void registerAddressInCache(const std::string& symbol, remote_addr_t address) const; // Methods std::vector unwindThread(pid_t tid) const; @@ -73,6 +81,7 @@ class AbstractProcessManager : public std::enable_shared_from_this @@ -86,25 +95,13 @@ class AbstractProcessManager : public std::enable_shared_from_this findPythonVersion() const; + void setPythonVersionFromDebugOffsets(); void setPythonVersion(const std::pair& version); bool versionIsAtLeast(int required_major, int required_minor) const; + std::pair pythonVersion() const; + bool isFreeThreaded() const; const python_v& offsets() const; - template - inline offset_t getFieldOffset(FieldPointer OffsetsStruct::*field) const - { - return (d_py_v->get().*field).offset; - } - - template - inline const typename FieldPointer::Type& - getField(const typename OffsetsStruct::Structure& obj, FieldPointer OffsetsStruct::*field) const - { - offset_t offset = getFieldOffset(field); - auto address = reinterpret_cast(&obj) + offset; - return *reinterpret_cast(address); - } - protected: // Data members pid_t d_pid; @@ -119,13 +116,26 @@ class AbstractProcessManager : public std::enable_shared_from_this d_debug_offsets{}; + mutable std::unordered_map d_type_cache; // Methods bool isValidInterpreterState(remote_addr_t addr) const; bool isValidDictionaryObject(remote_addr_t addr) const; private: + void warnIfOffsetsAreMismatched(remote_addr_t addr) const; + remote_addr_t findPyRuntimeFromElfData() const; + remote_addr_t findDebugOffsetsFromMaps() const; + + std::unique_ptr loadDebugOffsets(Structure& py_runtime) const; + bool copyDebugOffsets(Structure& py_runtime, python_v& debug_offsets) const; + bool validateDebugOffsets(const Structure& py_runtime, python_v& debug_offsets) const; + void clampSizes(python_v& debug_offsets) const; remote_addr_t scanMemoryAreaForInterpreterState(const VirtualMap& map) const; + remote_addr_t scanMemoryAreaForDebugOffsets(const VirtualMap& map) const; }; template @@ -138,32 +148,54 @@ AbstractProcessManager::copyObjectFromProcess(remote_addr_t addr, T* destination class ProcessManager : public AbstractProcessManager { public: + // Factory method + static std::shared_ptr create(pid_t pid, bool stop_process = true); + // Constructors ProcessManager( pid_t pid, const std::shared_ptr& tracer, const std::shared_ptr& analyzer, std::vector memory_maps, - MemoryMapInformation map_info); + std::optional main_map, + std::optional bss, + std::optional heap); + + // Destructors + virtual ~ProcessManager() = default; // Getters const std::vector& Tids() const override; private: // Data members - std::shared_ptr tracer; + std::shared_ptr d_tracer; std::vector d_tids; + + // Methods + void initializeVersion(pid_t pid, const ProcessMemoryMapInfo& map_info); }; class CoreFileProcessManager : public AbstractProcessManager { public: + // Factory method + static std::shared_ptr + create(const std::string& core_file, + const std::string& executable, + const std::optional& lib_search_path = std::nullopt); + // Constructors CoreFileProcessManager( pid_t pid, const std::shared_ptr& analyzer, std::vector memory_maps, - MemoryMapInformation map_info); + std::optional main_map, + std::optional bss, + std::optional heap); + + // Destructors + virtual ~CoreFileProcessManager() = default; // Getters const std::vector& Tids() const override; @@ -171,6 +203,8 @@ class CoreFileProcessManager : public AbstractProcessManager private: // Data members std::vector d_tids; - std::optional d_executable; + + // Methods + void initializeVersion(const std::string& core_file, const ProcessMemoryMapInfo& map_info); }; } // namespace pystack diff --git a/src/pystack/_pystack/process.pxd b/src/pystack/_pystack/process.pxd deleted file mode 100644 index cf750cd1..00000000 --- a/src/pystack/_pystack/process.pxd +++ /dev/null @@ -1,39 +0,0 @@ -from _pystack.elf_common cimport CoreFileAnalyzer -from _pystack.elf_common cimport ProcessAnalyzer -from _pystack.mem cimport MemoryMapInformation -from _pystack.mem cimport VirtualMap -from _pystack.mem cimport remote_addr_t -from libc.stdint cimport uintptr_t -from libcpp.memory cimport shared_ptr -from libcpp.string cimport string as cppstring -from libcpp.utility cimport pair -from libcpp.vector cimport vector - - -cdef extern from "process.h" namespace "pystack::AbstractProcessManager": - cdef enum InterpreterStatus: - RUNNING - FINALIZED - UNKNOWN - -cdef extern from "process.h" namespace "pystack": - cdef cppclass ProcessTracer: - pass - - cdef cppclass AbstractProcessManager: - remote_addr_t scanBSS() except+ - remote_addr_t scanHeap() except+ - remote_addr_t scanAllAnonymousMaps() except+ - remote_addr_t findInterpreterStateFromSymbols() except+ - remote_addr_t findInterpreterStateFromElfData() except+ - ssize_t copyMemoryFromProcess(remote_addr_t addr, ssize_t size, void *destination) except+ - vector[int] Tids() except+ - InterpreterStatus isInterpreterActive() except+ - pair[int, int] findPythonVersion() - void setPythonVersion(pair[int, int] version) - - cdef cppclass ProcessManager(AbstractProcessManager): - ProcessManager(int pid, shared_ptr[ProcessTracer] tracer, shared_ptr[ProcessAnalyzer] analyzer, vector[VirtualMap] memory_maps, MemoryMapInformation map_info) except+ - - cdef cppclass CoreFileProcessManager(AbstractProcessManager): - CoreFileProcessManager(int pid, shared_ptr[CoreFileAnalyzer] analyzer, vector[VirtualMap] memory_maps, MemoryMapInformation map_info) except+ diff --git a/src/pystack/_pystack/pycode.cpp b/src/pystack/_pystack/pycode.cpp index 2b5ecad6..874fa255 100644 --- a/src/pystack/_pystack/pycode.cpp +++ b/src/pystack/_pystack/pycode.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -104,11 +105,12 @@ static LocationInfo getLocationInfo( const std::shared_ptr& manager, remote_addr_t code_addr, - PyCodeObject& code, - uintptr_t last_instruction_index) + Structure& code, + uintptr_t last_instruction_index, + int tlbc_index) { - int code_lineno = manager->getField(code, &py_code_v::o_firstlineno); - remote_addr_t lnotab_addr = manager->getField(code, &py_code_v::o_lnotab); + int code_lineno = code.getField(&py_code_v::o_firstlineno); + remote_addr_t lnotab_addr = code.getField(&py_code_v::o_lnotab); LOG(DEBUG) << std::hex << std::showbase << "Copying lnotab data from address " << lnotab_addr; std::string lnotab = manager->getBytesFromAddress(lnotab_addr); @@ -119,8 +121,33 @@ getLocationInfo( // Check out https://github.com/python/cpython/blob/main/Objects/lnotab_notes.txt for the format of // the lnotab table in different versions of the interpreter. - if (manager->versionIsAtLeast(3, 11)) { - uintptr_t code_adaptive = code_addr + manager->getFieldOffset(&py_code_v::o_code_adaptive); + if (manager->versionIsAtLeast(3, 14) && manager->isFreeThreaded()) { + uintptr_t code_adaptive = code.getFieldRemoteAddress(&py_code_v::o_code_adaptive); + uintptr_t tlbc_entries_addr = code_adaptive - sizeof(void*); + uintptr_t tlbc_entries; + manager->copyMemoryFromProcess(tlbc_entries_addr, sizeof(tlbc_entries), &tlbc_entries); + Py_ssize_t tlbc_size; + manager->copyMemoryFromProcess(tlbc_entries, sizeof(tlbc_size), &tlbc_size); + std::vector vec(tlbc_size); + manager->copyMemoryFromProcess( + tlbc_entries + sizeof(tlbc_size), + tlbc_size * sizeof(uintptr_t), + vec.data()); + LOG(DEBUG) << "tlbc_index=" << tlbc_index << " tlbc_size=" << tlbc_size; + uintptr_t code_adaptive_actual = vec[tlbc_index]; + ptrdiff_t addrq = + (reinterpret_cast(last_instruction_index) + - reinterpret_cast(code_adaptive_actual)); + LocationInfo posinfo; + bool ret = parse_linetable(addrq, lnotab, code_lineno, &posinfo); + if (ret) { + location_info.lineno = posinfo.lineno; + location_info.end_lineno = posinfo.end_lineno; + location_info.column = posinfo.column; + location_info.end_column = posinfo.end_column; + } + } else if (manager->versionIsAtLeast(3, 11)) { + uintptr_t code_adaptive = code.getFieldRemoteAddress(&py_code_v::o_code_adaptive); ptrdiff_t addrq = (reinterpret_cast(last_instruction_index) - reinterpret_cast(code_adaptive)); @@ -164,35 +191,35 @@ getLocationInfo( CodeObject::CodeObject( const std::shared_ptr& manager, remote_addr_t addr, - uintptr_t lasti) + uintptr_t lasti, + int tlbc_index) { - PyCodeObject code; LOG(DEBUG) << std::hex << std::showbase << "Copying code struct from address " << addr; - manager->copyMemoryFromProcess(addr, manager->offsets().py_code.size, &code); + Structure code(manager, addr); - remote_addr_t filename_addr = manager->getField(code, &py_code_v::o_filename); + remote_addr_t filename_addr = code.getField(&py_code_v::o_filename); LOG(DEBUG) << std::hex << std::showbase << "Copying filename Python string from address " << filename_addr; d_filename = manager->getStringFromAddress(filename_addr); LOG(DEBUG) << "Code object filename: " << d_filename; - remote_addr_t name_addr = manager->getField(code, &py_code_v::o_name); + remote_addr_t name_addr = code.getField(&py_code_v::o_name); LOG(DEBUG) << std::hex << std::showbase << "Copying code name Python string from address " << name_addr; d_scope = manager->getStringFromAddress(name_addr); LOG(DEBUG) << "Code object scope: " << d_filename; LOG(DEBUG) << "Obtaining location info location"; - d_location_info = getLocationInfo(manager, addr, code, lasti); + d_location_info = getLocationInfo(manager, addr, code, lasti, tlbc_index); LOG(DEBUG) << "Code object location info: line_range=(" << d_location_info.lineno << ", " << d_location_info.end_lineno << ") column_range=(" << d_location_info.column << ", " << d_location_info.end_column << ")"; - d_narguments = manager->getField(code, &py_code_v::o_argcount); + d_narguments = code.getField(&py_code_v::o_argcount); LOG(DEBUG) << "Code object n arguments: " << d_narguments; LOG(DEBUG) << "Copying variable names"; - remote_addr_t varnames_addr = manager->getField(code, &py_code_v::o_varnames); + remote_addr_t varnames_addr = code.getField(&py_code_v::o_varnames); TupleObject varnames(manager, varnames_addr); std::transform( varnames.Items().cbegin(), @@ -205,6 +232,15 @@ CodeObject::CodeObject( }); } +CodeObject::CodeObject(std::string filename, std::string scope, LocationInfo location_info) +: d_filename(filename) +, d_scope(scope) +, d_location_info(location_info) +, d_narguments() +, d_varnames() +{ +} + std::string CodeObject::Filename() const { diff --git a/src/pystack/_pystack/pycode.h b/src/pystack/_pystack/pycode.h index 32c5531e..e58fa00c 100644 --- a/src/pystack/_pystack/pycode.h +++ b/src/pystack/_pystack/pycode.h @@ -26,7 +26,9 @@ class CodeObject CodeObject( const std::shared_ptr& manager, remote_addr_t addr, - uintptr_t lastli); + uintptr_t lastli, + int tlbc_index); + CodeObject(std::string filename, std::string scope, LocationInfo location_info); // Getters std::string Filename() const; @@ -41,8 +43,6 @@ class CodeObject std::string d_scope; LocationInfo d_location_info; int d_narguments; - - private: std::vector d_varnames; }; } // namespace pystack diff --git a/src/pystack/_pystack/pycode.pxd b/src/pystack/_pystack/pycode.pxd deleted file mode 100644 index 2596f87f..00000000 --- a/src/pystack/_pystack/pycode.pxd +++ /dev/null @@ -1,17 +0,0 @@ -from libcpp.string cimport string as cppstring -from libcpp.vector cimport vector - - -cdef extern from "pycode.h" namespace "pystack": - cdef struct LocationInfo: - int lineno - int end_lineno - int column - int end_column - - cdef cppclass CodeObject: - cppstring Filename() - cppstring Scope() - LocationInfo Location() - int NArguments() - const vector[cppstring] Varnames() diff --git a/src/pystack/_pystack/pyframe.cpp b/src/pystack/_pystack/pyframe.cpp index 16ecfb1a..9c41b244 100644 --- a/src/pystack/_pystack/pyframe.cpp +++ b/src/pystack/_pystack/pyframe.cpp @@ -10,8 +10,6 @@ #include "pytypes.h" #include "version.h" -static constexpr int FRAME_LIMIT = 4096; - namespace pystack { FrameObject::FrameObject( const std::shared_ptr& manager, @@ -19,11 +17,10 @@ FrameObject::FrameObject( ssize_t frame_no) : d_manager(manager) { - PyFrameObject frame; LOG(DEBUG) << "Copying frame number " << frame_no; LOG(DEBUG) << std::hex << std::showbase << "Copying frame struct from address " << addr; - manager->copyMemoryFromProcess(addr, manager->offsets().py_frame.size, &frame); + Structure frame(manager, addr); d_addr = addr; d_frame_no = frame_no; @@ -36,17 +33,35 @@ FrameObject::FrameObject( } else { d_code = getCode(manager, frame); } - std::tie(d_prev, d_is_entry) = getPrevAndIsEntry(manager, frame, next_frame_no); + + auto prev_addr = frame.getField(&py_frame_v::o_back); + LOG(DEBUG) << std::hex << std::showbase << "Previous frame address: " << prev_addr; + if (prev_addr) { + try { + d_prev = std::make_shared(manager, prev_addr, next_frame_no); + } catch (const RemoteMemCopyError& ex) { + // The previous frame address points to unreadable memory (e.g., guard page, + // unmapped region). Treat this as the end of the frame chain. + LOG(DEBUG) << "Failed to read previous frame at " << std::hex << std::showbase << prev_addr + << ", treating as end of frame chain: " << ex.what(); + d_prev = nullptr; + } + } + d_is_entry = isEntry(manager, frame); } bool FrameObject::getIsShim( const std::shared_ptr& manager, - const PyFrameObject& frame) + Structure& frame) { if (manager->versionIsAtLeast(3, 12)) { - constexpr int FRAME_OWNED_BY_CSTACK = 3; - return manager->getField(frame, &py_frame_v::o_owner) == FRAME_OWNED_BY_CSTACK; + int owner = frame.getField(&py_frame_v::o_owner); + if (manager->versionIsAtLeast(3, 14)) { + return owner == Python3_14::FRAME_OWNED_BY_CSTACK + || owner == Python3_14::FRAME_OWNED_BY_INTERPRETER; + } + return owner == Python3_12::FRAME_OWNED_BY_CSTACK; } return false; // Versions before 3.12 don't have shim frames. } @@ -54,63 +69,76 @@ FrameObject::getIsShim( std::unique_ptr FrameObject::getCode( const std::shared_ptr& manager, - const PyFrameObject& frame) + Structure& frame) { - remote_addr_t py_code_addr = manager->getField(frame, &py_frame_v::o_code); + remote_addr_t py_code_addr = frame.getField(&py_frame_v::o_code); + if (manager->versionIsAtLeast(3, 14)) { + py_code_addr = py_code_addr & (~3); + } + + if (py_code_addr == (remote_addr_t) nullptr) { + // In Python 3.14+, the base/sentinel frame at the bottom of each + // thread's frame stack has a NULL f_executable. This is an internal + // interpreter frame that should be skipped. + return nullptr; + } LOG(DEBUG) << std::hex << std::showbase << "Attempting to construct code object from address " << py_code_addr; uintptr_t last_instruction; if (manager->versionIsAtLeast(3, 11)) { - last_instruction = manager->getField(frame, &py_frame_v::o_prev_instr); + last_instruction = frame.getField(&py_frame_v::o_prev_instr); } else { - last_instruction = manager->getField(frame, &py_frame_v::o_lasti); + last_instruction = frame.getField(&py_frame_v::o_lasti); + } + int32_t tlbc_index = -1; + if (manager->versionIsAtLeast(3, 14) && manager->isFreeThreaded()) { + uintptr_t tlbc_index_addr = frame.getFieldRemoteAddress(&py_frame_v::o_prev_instr) + + sizeof(last_instruction) + sizeof(Python3_14::_PyStackRef); + manager->copyMemoryFromProcess(tlbc_index_addr, sizeof(tlbc_index), &tlbc_index); + } + try { + return std::make_unique(manager, py_code_addr, last_instruction, tlbc_index); + } catch (const RemoteMemCopyError& ex) { + // This may not have been a code object at all, or it may have been + // trashed by memory corruption. Either way, indicate that we failed + // to understand what code this frame is running. + return std::make_unique("???", "???", LocationInfo{0, 0, 0, 0}); } - return std::make_unique(manager, py_code_addr, last_instruction); } -std::pair, bool> -FrameObject::getPrevAndIsEntry( +bool +FrameObject::isEntry( const std::shared_ptr& manager, - const PyFrameObject& frame, - ssize_t next_frame_no) + Structure& frame) { - auto prev_addr = manager->getField(frame, &py_frame_v::o_back); - LOG(DEBUG) << std::hex << std::showbase << "Previous frame address: " << prev_addr; - - std::shared_ptr prev; - if (prev_addr) { - prev = std::make_shared(manager, prev_addr, next_frame_no); - } - - bool is_entry; if (manager->versionIsAtLeast(3, 12)) { - // This is an entry frame if the previous frame was a shim. - // The previous frame should also be skipped in that case. - is_entry = prev && prev->d_is_shim; - if (is_entry) { - prev = prev->d_prev; - } + // This is an entry frame if the previous frame was a shim, or if + // this is the most recent frame and is itself a shim (meaning that + // the entry frame this shim was created for hasn't been pushed yet, + // so the latest _PyEval_EvalFrameDefault call has no Python frames). + return (d_prev && d_prev->d_is_shim) || (d_frame_no == 0 && d_is_shim); } else if (manager->versionIsAtLeast(3, 11)) { // This is an entry frame if it has an entry flag set. - is_entry = manager->getField(frame, &py_frame_v::o_is_entry); - } else { - // This is an entry frame, as all frames prior to 3.11 were. - is_entry = true; + return frame.getField(&py_frame_v::o_is_entry); } - - return std::make_pair(prev, is_entry); + return true; } void FrameObject::resolveLocalVariables() { LOG(DEBUG) << "Resolving local variables from frame number " << d_frame_no; + if (d_code == nullptr) { + LOG(INFO) << "Frame is a shim frame, skipping local variable resolution"; + return; + } const size_t n_arguments = d_code->NArguments(); const size_t n_locals = d_code->Varnames().size(); - const remote_addr_t locals_addr = d_addr + d_manager->getFieldOffset(&py_frame_v::o_localsplus); + Structure frame(d_manager, d_addr); + const remote_addr_t locals_addr = frame.getFieldRemoteAddress(&py_frame_v::o_localsplus); if (n_locals < n_arguments) { throw std::runtime_error("Found more arguments than local variables"); @@ -126,6 +154,12 @@ FrameObject::resolveLocalVariables() return; } + if (d_manager->versionIsAtLeast(3, 14)) { + // In Python 3.14, the local variable is a PyStackRef: a pointer + // with extra flags set in its low bits. Ignore the flags. + addr = addr & (~3); + } + std::string key = d_code->Varnames()[index]; LOG(DEBUG) << "Copying local variable at address " << std::hex << std::showbase << addr; @@ -179,4 +213,10 @@ FrameObject::IsEntryFrame() const return this->d_is_entry; } +bool +FrameObject::IsShim() const +{ + return this->d_is_shim; +} + } // namespace pystack diff --git a/src/pystack/_pystack/pyframe.h b/src/pystack/_pystack/pyframe.h index fa8b6dc2..69d9127b 100644 --- a/src/pystack/_pystack/pyframe.h +++ b/src/pystack/_pystack/pyframe.h @@ -1,11 +1,12 @@ #pragma once -#include "memory" -#include "unordered_map" +#include +#include #include "mem.h" #include "process.h" #include "pycode.h" +#include "structure.h" namespace pystack { @@ -25,22 +26,22 @@ class FrameObject const std::unordered_map& Arguments() const; const std::unordered_map& Locals() const; bool IsEntryFrame() const; + bool IsShim() const; // Methods void resolveLocalVariables(); private: // Methods - static bool - getIsShim(const std::shared_ptr& manager, const PyFrameObject& frame); + static bool getIsShim( + const std::shared_ptr& manager, + Structure& frame); static std::unique_ptr - getCode(const std::shared_ptr& manager, const PyFrameObject& frame); + getCode(const std::shared_ptr& manager, Structure& frame); - static std::pair, bool> getPrevAndIsEntry( - const std::shared_ptr& manager, - const PyFrameObject& frame, - ssize_t frame_no); + bool + isEntry(const std::shared_ptr& manager, Structure& frame); // Data members const std::shared_ptr d_manager{}; diff --git a/src/pystack/_pystack/pyframe.pxd b/src/pystack/_pystack/pyframe.pxd deleted file mode 100644 index e51403e3..00000000 --- a/src/pystack/_pystack/pyframe.pxd +++ /dev/null @@ -1,18 +0,0 @@ -from _pystack.pycode cimport CodeObject -from libcpp cimport bool -from libcpp.memory cimport unique_ptr -from libcpp.string cimport string as cppstring -from libcpp.unordered_map cimport unordered_map - - -cdef extern from "pyframe.h" namespace "pystack": - - cdef cppclass FrameObject: - ssize_t FrameNo() - unique_ptr[FrameObject] PreviousFrame() - unique_ptr[CodeObject] Code() - unordered_map[cppstring, cppstring] Arguments() - unordered_map[cppstring, cppstring] Locals() - bool IsEntryFrame() - - void resolveLocalVariables() except+ diff --git a/src/pystack/_pystack/pythread.cpp b/src/pystack/_pystack/pythread.cpp index 0e9aafcd..857be24c 100644 --- a/src/pystack/_pystack/pythread.cpp +++ b/src/pystack/_pystack/pythread.cpp @@ -6,9 +6,9 @@ #include "mem.h" #include "native_frame.h" #include "process.h" -#include "pycompat.h" #include "pyframe.h" #include "pythread.h" +#include "structure.h" #include "version.h" #include "cpython/pthread.h" @@ -47,10 +47,9 @@ findPthreadTidOffset( remote_addr_t interp_state_addr) { LOG(DEBUG) << "Attempting to locate tid offset in pthread structure"; - PyInterpreterState is; - manager->copyObjectFromProcess(interp_state_addr, &is); + Structure is(manager, interp_state_addr); - auto current_thread_addr = manager->getField(is, &py_is_v::o_tstate_head); + auto current_thread_addr = is.getField(&py_is_v::o_tstate_head); auto thread_head = current_thread_addr; @@ -64,23 +63,26 @@ findPthreadTidOffset( // pthread' that we know about to avoid having to do guess-work by doing a // linear scan over the struct. while (current_thread_addr != (remote_addr_t) nullptr) { - PyThreadState current_thread; - manager->copyObjectFromProcess(current_thread_addr, ¤t_thread); - auto pthread_id_addr = manager->getField(current_thread, &py_thread_v::o_thread_id); + Structure current_thread(manager, current_thread_addr); + auto pthread_id_addr = current_thread.getField(&py_thread_v::o_thread_id); pid_t the_tid; std::vector glibc_pthread_offset_candidates = { offsetof(_pthread_structure_with_simple_header, tid), offsetof(_pthread_structure_with_tcbhead, tid)}; for (off_t candidate : glibc_pthread_offset_candidates) { - manager->copyObjectFromProcess((remote_addr_t)(pthread_id_addr + candidate), &the_tid); + try { + manager->copyObjectFromProcess((remote_addr_t)(pthread_id_addr + candidate), &the_tid); + } catch (const RemoteMemCopyError& ex) { + continue; + } if (the_tid == manager->Pid()) { LOG(DEBUG) << "Tid offset located using GLIBC offsets at offset " << std::showbase << std::hex << candidate << " in pthread structure"; return candidate; } } - remote_addr_t next_thread_addr = manager->getField(current_thread, &py_thread_v::o_next); + remote_addr_t next_thread_addr = current_thread.getField(&py_thread_v::o_next); if (next_thread_addr == current_thread_addr) { break; } @@ -91,9 +93,8 @@ findPthreadTidOffset( current_thread_addr = thread_head; while (current_thread_addr != (remote_addr_t) nullptr) { - PyThreadState current_thread; - manager->copyObjectFromProcess(current_thread_addr, ¤t_thread); - auto pthread_id_addr = manager->getField(current_thread, &py_thread_v::o_thread_id); + Structure current_thread(manager, current_thread_addr); + auto pthread_id_addr = current_thread.getField(&py_thread_v::o_thread_id); // Attempt to locate a field in the pthread struct that's equal to the pid. uintptr_t buffer[100]; @@ -118,7 +119,7 @@ findPthreadTidOffset( } } - remote_addr_t next_thread_addr = manager->getField(current_thread, &py_thread_v::o_next); + remote_addr_t next_thread_addr = current_thread.getField(&py_thread_v::o_next); if (next_thread_addr == current_thread_addr) { break; } @@ -128,28 +129,76 @@ findPthreadTidOffset( return 0; } +remote_addr_t +getStackAnchor(const std::shared_ptr& manager, remote_addr_t frame_addr) +{ + if (!frame_addr) { + return 0; + } + if (!manager->versionIsAtLeast(3, 11)) { + return frame_addr; + } + + remote_addr_t current_addr = frame_addr; + for (int i = 0; i < 4096 && current_addr; ++i) { + Structure current_frame(manager, current_addr); + + if (manager->versionIsAtLeast(3, 14)) { + auto owner = current_frame.getField(&py_frame_v::o_owner); + if (owner == Python3_14::FRAME_OWNED_BY_INTERPRETER + || owner == Python3_14::FRAME_OWNED_BY_CSTACK) + { + return current_addr; + } + } else if (manager->versionIsAtLeast(3, 12)) { + auto owner = current_frame.getField(&py_frame_v::o_owner); + if (owner == Python3_12::FRAME_OWNED_BY_CSTACK) { + return current_addr; + } + } else { + if (current_frame.getField(&py_frame_v::o_is_entry)) { + return current_addr; + } + } + + remote_addr_t next_addr = current_frame.getField(&py_frame_v::o_back); + if (next_addr == current_addr) { + break; + } + current_addr = next_addr; + } + return frame_addr; +} + +remote_addr_t +PyThread::stackAnchor() const +{ + return d_stack_anchor; +} + PyThread::PyThread(const std::shared_ptr& manager, remote_addr_t addr) : Thread(0, 0) { d_pid = manager->Pid(); - PyThreadState ts; LOG(DEBUG) << std::hex << std::showbase << "Copying main thread struct from address " << addr; - manager->copyObjectFromProcess(addr, &ts); + Structure ts(manager, addr); remote_addr_t frame_addr = getFrameAddr(manager, ts); if (frame_addr != (remote_addr_t) nullptr) { LOG(DEBUG) << std::hex << std::showbase << "Attempting to construct frame from address " << frame_addr; d_first_frame = std::make_unique(manager, frame_addr, 0); + + d_stack_anchor = getStackAnchor(manager, frame_addr); } d_addr = addr; - remote_addr_t candidate_next_addr = manager->getField(ts, &py_thread_v::o_next); + remote_addr_t candidate_next_addr = ts.getField(&py_thread_v::o_next); d_next_addr = candidate_next_addr == addr ? (remote_addr_t) nullptr : candidate_next_addr; - d_pthread_id = manager->getField(ts, &py_thread_v::o_thread_id); - d_tid = getThreadTid(manager, addr, d_pthread_id); + d_pthread_id = ts.getField(&py_thread_v::o_thread_id); + d_tid = getThreadTid(manager, ts, d_pthread_id); d_next = nullptr; if (d_next_addr != (remote_addr_t)NULL) { @@ -165,14 +214,12 @@ PyThread::PyThread(const std::shared_ptr& manager, int PyThread::getThreadTid( const std::shared_ptr& manager, - remote_addr_t thread_addr, + Structure& ts, unsigned long pthread_id) { int the_tid = -1; if (manager->versionIsAtLeast(3, 11)) { - manager->copyObjectFromProcess( - (remote_addr_t)(thread_addr + manager->getFieldOffset(&py_thread_v::o_native_thread_id)), - &the_tid); + the_tid = ts.getField(&py_thread_v::o_native_thread_id); } else { the_tid = inferTidFromPThreadStructure(manager, pthread_id); } @@ -219,19 +266,18 @@ PyThread::inferTidFromPThreadStructure( remote_addr_t PyThread::getFrameAddr( const std::shared_ptr& manager, - const PyThreadState& ts) + Structure& ts) { - if (manager->versionIsAtLeast(3, 11)) { - remote_addr_t cframe_addr = manager->getField(ts, &py_thread_v::o_frame); + if (manager->versionIsAtLeast(3, 11) && !manager->versionIsAtLeast(3, 13)) { + remote_addr_t cframe_addr = ts.getField(&py_thread_v::o_frame); if (!manager->isAddressValid(cframe_addr)) { return reinterpret_cast(nullptr); } - CFrame cframe; - manager->copyObjectFromProcess(cframe_addr, &cframe); - return manager->getField(cframe, &py_cframe_v::current_frame); + Structure cframe(manager, cframe_addr); + return cframe.getField(&py_cframe_v::current_frame); } else { - return manager->getField(ts, &py_thread_v::o_frame); + return ts.getField(&py_thread_v::o_frame); } } @@ -261,7 +307,7 @@ PyThread::isGCCollecting() const PyThread::GilStatus PyThread::calculateGilStatus( - PyThreadState& ts, + Structure& ts, const std::shared_ptr& manager) const { LOG(DEBUG) << "Attempting to determine GIL Status"; @@ -277,25 +323,20 @@ PyThread::calculateGilStatus( // a ceval state, which points to a GIL runtime state. // If that GIL state has `locked` set and `last_holder` is d_addr, // then the thread represented by this PyThread holds the GIL. - PyInterpreterState interp; - auto is_addr = manager->getField(ts, &py_thread_v::o_interp); - manager->copyObjectFromProcess(is_addr, &interp); + auto is_addr = ts.getField(&py_thread_v::o_interp); + Structure interp(manager, is_addr); - auto gil_addr = manager->getField(interp, &py_is_v::o_gil_runtime_state); - - Python3_9::_gil_runtime_state gil; - manager->copyObjectFromProcess(gil_addr, &gil); - - auto locked = *reinterpret_cast(&gil.locked); - auto holder = *reinterpret_cast(&gil.last_holder); + auto gil_addr = interp.getField(&py_is_v::o_gil_runtime_state); + Structure gil(manager, gil_addr); + auto locked = gil.getField(&py_gilruntimestate_v::o_locked); + auto holder = gil.getField(&py_gilruntimestate_v::o_last_holder); return (locked && holder == d_addr ? GilStatus::HELD : GilStatus::NOT_HELD); } else if (manager->versionIsAtLeast(3, 8)) { // Fast, exact method by checking the gilstate structure in _PyRuntime LOG(DEBUG) << "Searching for the GIL by checking the value of 'tstate_current'"; - PyRuntimeState runtime; - manager->copyObjectFromProcess(pyruntime, &runtime); - uintptr_t tstate_current = manager->getField(runtime, &py_runtime_v::o_tstate_current); + Structure runtime(manager, pyruntime); + uintptr_t tstate_current = runtime.getField(&py_runtime_v::o_tstate_current); return (tstate_current == d_addr ? GilStatus::HELD : GilStatus::NOT_HELD); } else { LOG(DEBUG) << "Searching for the GIL by scanning the _PyRuntime structure"; @@ -338,32 +379,31 @@ PyThread::calculateGilStatus( PyThread::GCStatus PyThread::calculateGCStatus( - PyThreadState& ts, + Structure& ts, const std::shared_ptr& manager) const { LOG(DEBUG) << "Attempting to determine GC Status"; - GCRuntimeState gcstate; + remote_addr_t gcstate_addr; if (manager->versionIsAtLeast(3, 9)) { - PyInterpreterState interp; - auto is_addr = manager->getField(ts, &py_thread_v::o_interp); - manager->copyObjectFromProcess(is_addr, &interp); - gcstate = manager->getField(interp, &py_is_v ::o_gc); + auto is_addr = ts.getField(&py_thread_v::o_interp); + Structure interp(manager, is_addr); + gcstate_addr = interp.getFieldRemoteAddress(&py_is_v::o_gc); } else if (manager->versionIsAtLeast(3, 7)) { remote_addr_t pyruntime = manager->findSymbol("_PyRuntime"); if (!pyruntime) { LOG(DEBUG) << "Failed to get GC status because the _PyRuntime symbol is unavailable"; return GCStatus::COLLECTING_UNKNOWN; } - PyRuntimeState runtime; - manager->copyObjectFromProcess(pyruntime, &runtime); - gcstate = manager->getField(runtime, &py_runtime_v::o_gc); + Structure runtime(manager, pyruntime); + gcstate_addr = runtime.getFieldRemoteAddress(&py_runtime_v::o_gc); } else { LOG(DEBUG) << "GC Status retrieval not supported by this Python version"; return GCStatus::COLLECTING_UNKNOWN; } - auto collecting = manager->getField(gcstate, &py_gc_v::o_collecting); + Structure gcstate(manager, gcstate_addr); + auto collecting = gcstate.getField(&py_gc_v::o_collecting); LOG(DEBUG) << "GC status correctly retrieved: " << collecting; return collecting ? GCStatus::COLLECTING : GCStatus::NOT_COLLECTING; } @@ -375,15 +415,13 @@ getThreadFromInterpreterState( const std::shared_ptr& manager, remote_addr_t addr) { - if (tid_offset_in_pthread_struct == 0) { + if (tid_offset_in_pthread_struct == 0 && !manager->versionIsAtLeast(3, 11)) { tid_offset_in_pthread_struct = findPthreadTidOffset(manager, addr); } LOG(DEBUG) << std::hex << std::showbase << "Copying PyInterpreterState struct from address " << addr; - PyInterpreterState is; - manager->copyObjectFromProcess(addr, &is); - - auto thread_addr = manager->getField(is, &py_is_v::o_tstate_head); + Structure is(manager, addr); + auto thread_addr = is.getField(&py_is_v::o_tstate_head); return std::make_shared(manager, thread_addr); } diff --git a/src/pystack/_pystack/pythread.h b/src/pystack/_pystack/pythread.h index 93b9626d..bb490ffb 100644 --- a/src/pystack/_pystack/pythread.h +++ b/src/pystack/_pystack/pythread.h @@ -45,10 +45,12 @@ class PyThread : public Thread // Methods GilStatus isGilHolder() const; GCStatus isGCCollecting() const; + remote_addr_t stackAnchor() const; // Static Methods - static remote_addr_t - getFrameAddr(const std::shared_ptr& manager, const PyThreadState& ts); + static remote_addr_t getFrameAddr( + const std::shared_ptr& manager, + Structure& ts); private: // Data members @@ -59,13 +61,14 @@ class PyThread : public Thread remote_addr_t d_next_addr; std::shared_ptr d_next; std::shared_ptr d_first_frame; + remote_addr_t d_stack_anchor{}; // Methods GilStatus calculateGilStatus( - PyThreadState& ts, + Structure& ts, const std::shared_ptr& manager) const; GCStatus calculateGCStatus( - PyThreadState& ts, + Structure& ts, const std::shared_ptr& manager) const; // Static Methods @@ -74,7 +77,7 @@ class PyThread : public Thread unsigned long pthread_id); static int getThreadTid( const std::shared_ptr& manager, - remote_addr_t thread_addr, + Structure& ts, unsigned long pthread_id); }; diff --git a/src/pystack/_pystack/pythread.pxd b/src/pystack/_pystack/pythread.pxd deleted file mode 100644 index 3930a6de..00000000 --- a/src/pystack/_pystack/pythread.pxd +++ /dev/null @@ -1,37 +0,0 @@ -from _pystack.native_frame cimport NativeFrame -from _pystack.process cimport AbstractProcessManager -from _pystack.process cimport remote_addr_t -from _pystack.pyframe cimport FrameObject -from libcpp.memory cimport shared_ptr -from libcpp.string cimport string as cppstring -from libcpp.vector cimport vector - - -cdef extern from "pythread.h" namespace "pystack": - cdef cppclass NativeThread "pystack::Thread": - NativeThread(int, int) except+ - int Tid() - vector[NativeFrame]& NativeFrames() - void populateNativeStackTrace(shared_ptr[AbstractProcessManager] manager) except+ - -cdef extern from "pythread.h" namespace "pystack::PyThread": - cdef enum GilStatus: - UNKNOWN = -1 - NOT_HELD = 0 - HELD = 1 - - cdef enum GCStatus: - COLLECTING_UNKNOWN = -1 - NOT_COLLECTING = 0 - COLLECTING = 1 - -cdef extern from "pythread.h" namespace "pystack": - cdef cppclass Thread "pystack::PyThread": - int Tid() - shared_ptr[FrameObject] FirstFrame() - shared_ptr[Thread] NextThread() - vector[NativeFrame]& NativeFrames() - GilStatus isGilHolder() - GCStatus isGCCollecting() - void populateNativeStackTrace(shared_ptr[AbstractProcessManager] manager) except+ - shared_ptr[Thread] getThreadFromInterpreterState(shared_ptr[AbstractProcessManager] manager, remote_addr_t addr) except+ diff --git a/src/pystack/_pystack/pytypes.cpp b/src/pystack/_pystack/pytypes.cpp index e64d4af8..650882d9 100644 --- a/src/pystack/_pystack/pytypes.cpp +++ b/src/pystack/_pystack/pytypes.cpp @@ -7,6 +7,7 @@ #include "logging.h" #include "pytypes.h" +#include "structure.h" #include "version.h" namespace pystack { @@ -88,17 +89,15 @@ TupleObject::TupleObject( { d_manager = manager; - PyTupleObject tuple; - manager->copyObjectFromProcess(addr, &tuple); - - ssize_t num_items = tuple.ob_base.ob_size; + Structure tuple(manager, addr); + ssize_t num_items = tuple.getField(&py_tuple_v::o_ob_size); if (num_items == 0) { LOG(DEBUG) << std::hex << std::showbase << "There are no elements in this tuple"; return; } d_items.resize(num_items); manager->copyMemoryFromProcess( - addr + offsetof(PyTupleObject, ob_item), + tuple.getFieldRemoteAddress(&py_tuple_v::o_ob_item), num_items * sizeof(PyObject*), d_items.data()); } @@ -120,17 +119,15 @@ ListObject::ListObject(const std::shared_ptr& mana { d_manager = manager; - PyListObject list; - manager->copyObjectFromProcess(addr, &list); - - ssize_t num_items = list.ob_base.ob_size; + Structure list(manager, addr); + ssize_t num_items = list.getField(&py_list_v::o_ob_size); if (num_items == 0) { LOG(DEBUG) << std::hex << std::showbase << "There are no elements in this list"; return; } d_items.resize(num_items); manager->copyMemoryFromProcess( - (remote_addr_t)list.ob_item, + (remote_addr_t)list.getField(&py_list_v::o_ob_item), num_items * sizeof(PyObject*), d_items.data()); } @@ -160,18 +157,18 @@ LongObject::LongObject( constexpr unsigned int shift = 15; #endif - _PyLongObject longobj; - manager->copyObjectFromProcess(addr, &longobj); + Structure longobj(manager, addr); ssize_t size; bool negative; + Py_ssize_t ob_size = longobj.getField(&py_long_v::o_ob_size); if (manager->versionIsAtLeast(3, 12)) { - auto lv_tag = *reinterpret_cast(&longobj.ob_base.ob_size); + auto lv_tag = *reinterpret_cast(&ob_size); negative = (lv_tag & 3) == 2; size = lv_tag >> 3; } else { - negative = longobj.ob_base.ob_size < 0; - size = std::abs(longobj.ob_base.ob_size); + negative = ob_size < 0; + size = std::abs(ob_size); } if (size == 0) { @@ -199,7 +196,7 @@ LongObject::LongObject( std::vector digits; digits.resize(size); manager->copyMemoryFromProcess( - addr + offsetof(_PyLongObject, ob_digit), + longobj.getFieldRemoteAddress(&py_long_v::o_ob_digit), sizeof(digit) * size, digits.data()); for (ssize_t i = 0; i < size; ++i) { @@ -249,26 +246,24 @@ LongObject::Overflowed() const void getDictEntries( const std::shared_ptr& manager, - const Python3::PyDictObject& dict, + Structure& dict, ssize_t& num_items, std::vector& valid_entries) { - auto keys_addr = reinterpret_cast(dict.ma_keys); assert(manager->versionIsAtLeast(3, 0)); + remote_addr_t keys_addr = dict.getField(&py_dict_v::o_ma_keys); ssize_t dk_size = 0; int dk_kind = 0; + Structure keys(manager, keys_addr); + num_items = keys.getField(&py_dictkeys_v::o_dk_nentries); + dk_size = keys.getField(&py_dictkeys_v::o_dk_size); + if (manager->versionIsAtLeast(3, 11)) { - Python3_11::PyDictKeysObject keys; - manager->copyObjectFromProcess(keys_addr, &keys); - num_items = keys.dk_nentries; - dk_size = 1L << keys.dk_log2_size; - dk_kind = keys.dk_kind; - } else { - Python3_3::PyDictKeysObject keys; - manager->copyObjectFromProcess(keys_addr, &keys); - num_items = keys.dk_nentries; - dk_size = keys.dk_size; + // We're reusing the o_dk_size offset for dk_log2_size. Fix up the value. + dk_size = 1L << dk_size; + // Added in 3.11 + dk_kind = keys.getField(&py_dictkeys_v::o_dk_kind); } if (num_items == 0) { LOG(DEBUG) << std::hex << std::showbase << "There are no elements in this dict"; @@ -293,14 +288,8 @@ getDictEntries( offset = 8 * dk_size; } - offset_t dk_indices_offset = 0; - if (manager->versionIsAtLeast(3, 11)) { - dk_indices_offset = offsetof(Python3_11::PyDictKeysObject, dk_indices); - } else { - dk_indices_offset = offsetof(Python3_3::PyDictKeysObject, dk_indices); - } - - remote_addr_t entries_addr = keys_addr + dk_indices_offset + offset; + offset_t dk_indices_addr = keys.getFieldRemoteAddress(&py_dictkeys_v::o_dk_indices); + remote_addr_t entries_addr = dk_indices_addr + offset; std::vector raw_entries; raw_entries.resize(num_items); @@ -316,9 +305,7 @@ getDictEntries( unicode_entries.cbegin(), unicode_entries.cend(), std::back_inserter(raw_entries), - [](auto& entry) { - return Python3::PyDictKeyEntry{0, entry.me_key, entry.me_value}; - }); + [](auto& entry) { return Python3::PyDictKeyEntry{0, entry.me_key, entry.me_value}; }); } else { manager->copyMemoryFromProcess( entries_addr, @@ -369,8 +356,7 @@ DictObject::DictObject(std::shared_ptr manager, re void DictObject::loadFromPython3(remote_addr_t addr) { - Python3::PyDictObject dict; - d_manager->copyObjectFromProcess(addr, &dict); + Structure dict(d_manager, addr); ssize_t num_items; std::vector valid_entries; @@ -399,11 +385,13 @@ DictObject::loadFromPython3(remote_addr_t addr) * All dicts sharing same key must have same insertion order. */ - auto values_addr = (remote_addr_t)dict.ma_values; + remote_addr_t dictvalues_addr = dict.getField(&py_dict_v::o_ma_values); + Structure dictvalues(d_manager, dictvalues_addr); // Get the values in one copy if we are dealing with a split-table dictionary - if (values_addr != 0) { + if (dictvalues_addr != 0) { d_values.resize(num_items); + auto values_addr = dictvalues.getFieldRemoteAddress(&py_dictvalues_v::o_values); d_manager->copyMemoryFromProcess(values_addr, num_items * sizeof(PyObject*), d_values.data()); } else { std::transform( @@ -498,13 +486,12 @@ std::string GenericObject::toString(ssize_t max_size) const { std::stringstream os; - os << "<" << std::hex << std::showbase << d_classname << " at " << d_addr << ">"; + os << "<" << std::hex << d_classname << " at 0x" << d_addr << ">"; std::string object_str = os.str(); return limitOutput(object_str, max_size); } NoneObject::NoneObject(remote_addr_t addr) -: d_addr(addr) { } @@ -516,20 +503,36 @@ NoneObject::toString([[maybe_unused]] ssize_t max_size) const Object::Object(const std::shared_ptr& manager, remote_addr_t addr) : d_addr(addr) +, d_type_addr(0) +, d_classname() +, d_flags() , d_manager(manager) { LOG(DEBUG) << std::hex << std::showbase << "Copying PyObject data from address " << addr; - PyObject obj; - manager->copyObjectFromProcess(d_addr, &obj); - - PyTypeObject cls; - LOG(DEBUG) << std::hex << std::showbase << "Copying typeobject from address " << obj.ob_type; - manager->copyMemoryFromProcess((remote_addr_t)obj.ob_type, manager->offsets().py_type.size, &cls); + Structure obj(manager, addr); + try { + obj.copyFromRemote(); + } catch (RemoteMemCopyError& ex) { + LOG(WARNING) << std::hex << std::showbase << "Failed to read PyObject data from address " + << d_addr; + d_classname = "invalid object"; + return; + } - d_flags = manager->getField(cls, &py_type_v::o_tp_flags); + d_type_addr = obj.getField(&py_object_v::o_ob_type); + LOG(DEBUG) << std::hex << std::showbase << "Copying typeobject from address " << d_type_addr; + Structure cls(manager, d_type_addr); + try { + d_flags = cls.getField(&py_type_v::o_tp_flags); + } catch (RemoteMemCopyError& ex) { + LOG(WARNING) << std::hex << std::showbase << "Failed to read typeobject from address " + << d_type_addr; + d_classname = "invalid object"; + return; + } - remote_addr_t name_addr = manager->getField(cls, &py_type_v::o_tp_name); + remote_addr_t name_addr = cls.getField(&py_type_v::o_tp_name); try { d_classname = manager->getCStringFromAddress(name_addr); } catch (RemoteMemCopyError& ex) { @@ -547,6 +550,12 @@ Object::hasFlags(unsigned long flags) const return flags & d_flags; } +remote_addr_t +Object::typeAddr() const +{ + return d_type_addr; +} + // Helpers for making overloaded lambdas in the variant visitor in Object::toString template struct overloaded : Ts... @@ -565,24 +574,33 @@ Object::toString(ssize_t max_size) const if (max_size <= 5) { return ELLIPSIS; } - std::stringstream os; - std::visit( - overloaded{ - [&](const auto& arg) { os << arg.toString(max_size); }, - [&](const bool arg) { os << arg; }, - [&](const long arg) { os << arg; }, - [&](const double arg) { os << arg; }, - [&](const std::string& arg) { - std::string truncated = arg; - if (static_cast(max_size) < arg.size()) { - truncated = arg.substr(0, max_size - 3) + "..."; - } - os << truncated; - }, - }, - toConcreteObject()); + try { + std::stringstream os; + std::visit( + overloaded{ + [&](const auto& arg) { os << arg.toString(max_size); }, + [&](const bool arg) { os << arg; }, + [&](const long arg) { os << arg; }, + [&](const double arg) { os << arg; }, + [&](const std::string& arg) { + std::string truncated = arg; + if (static_cast(max_size) < arg.size()) { + truncated = arg.substr(0, max_size - 3) + "..."; + } + os << truncated; + }, + }, + toConcreteObject()); + + return os.str(); + } catch (RemoteMemCopyError& ex) { + LOG(WARNING) << std::hex << std::showbase << "Failed to create a repr for object of type " + << d_classname << " at address " << d_addr; - return os.str(); + std::stringstream os; + os << std::hex << std::showbase << "<" << d_classname << " object at " << d_addr << ">"; + return os.str(); + } } long @@ -596,9 +614,8 @@ Object::toInteger() const double Object::toFloat() const { - PyFloatObject the_float; - d_manager->copyObjectFromProcess(d_addr, &the_float); - return the_float.ob_fval; + Structure the_float(d_manager, d_addr); + return the_float.getField(&py_float_v::o_ob_fval); } bool @@ -652,6 +669,8 @@ Object::objectType() const return ObjectType::FLOAT; } else if (d_classname == "NoneType") { return ObjectType::NONE; + } else if (d_classname == "code") { + return ObjectType::CODE; } return ObjectType::OTHER; } @@ -686,6 +705,7 @@ Object::toConcreteObject() const return ListObject(d_manager, d_addr); case Object::ObjectType::DICT: return DictObject(d_manager, d_addr); + case Object::ObjectType::CODE: case Object::ObjectType::OTHER: return GenericObject(d_addr, d_classname); } @@ -696,9 +716,9 @@ Object::toConcreteObject() const } std::string -Object::guessClassName(PyTypeObject& type) const +Object::guessClassName(Structure& type) const { - remote_addr_t tp_repr = d_manager->getField(type, &py_type_v::o_tp_repr); + remote_addr_t tp_repr = type.getField(&py_type_v::o_tp_repr); if (tp_repr == d_manager->findSymbol("float_repr")) { return "float"; } @@ -708,6 +728,9 @@ Object::guessClassName(PyTypeObject& type) const if (tp_repr == d_manager->findSymbol("bool_repr")) { return "bool"; } + if (tp_repr == d_manager->findSymbol("code_repr")) { + return "PyCodeObject"; + } return "???"; } diff --git a/src/pystack/_pystack/pytypes.h b/src/pystack/_pystack/pytypes.h index 99e8f9b6..d2d6681b 100644 --- a/src/pystack/_pystack/pytypes.h +++ b/src/pystack/_pystack/pytypes.h @@ -1,14 +1,13 @@ #pragma once #include -#include #include #include #include #include "mem.h" #include "process.h" -#include "pycompat.h" +#include "structure.h" namespace pystack { @@ -119,9 +118,6 @@ class NoneObject // Methods std::string toString(ssize_t max_size) const; - - private: - remote_addr_t d_addr; }; class Object @@ -153,7 +149,8 @@ class Object TUPLE, LIST, DICT, - OTHER, + CODE, + OTHER }; static constexpr int MAX_LOCAL_STR_SIZE = 80; @@ -166,10 +163,12 @@ class Object PythonObject toConcreteObject() const; bool hasFlags(unsigned long flags) const; std::string toString(ssize_t max_size = MAX_LOCAL_STR_SIZE) const; + remote_addr_t typeAddr() const; private: // Data members remote_addr_t d_addr; + remote_addr_t d_type_addr; std::string d_classname{}; unsigned long d_flags{}; std::shared_ptr d_manager{nullptr}; @@ -178,7 +177,7 @@ class Object bool toBool() const; long toInteger() const; double toFloat() const; - std::string guessClassName(PyTypeObject& type) const; + std::string guessClassName(Structure& type) const; }; } // namespace pystack diff --git a/src/pystack/_pystack/structure.h b/src/pystack/_pystack/structure.h new file mode 100644 index 00000000..e3ee6af4 --- /dev/null +++ b/src/pystack/_pystack/structure.h @@ -0,0 +1,91 @@ +#pragma once + +#include +#include + +#include "process.h" + +namespace pystack { + +template +class Structure +{ + public: + // Constructors + Structure(std::shared_ptr manager, remote_addr_t addr); + Structure(const Structure&) = delete; + Structure& operator=(const Structure&) = delete; + + // Methods + void copyFromRemote(); + + template + remote_addr_t getFieldRemoteAddress(FieldPointer OffsetsStruct::* field) const; + + template + const typename FieldPointer::Type& getField(FieldPointer OffsetsStruct::* field); + + private: + // Data members + std::shared_ptr d_manager; + remote_addr_t d_addr; + ssize_t d_size; + std::array d_footprintbuf; + std::vector d_heapbuf; + char* d_buf; +}; + +template +inline Structure::Structure( + std::shared_ptr manager, + remote_addr_t addr) +: d_manager(manager) +, d_addr(addr) +, d_size(d_manager->offsets().get().size) +, d_buf{} +{ +} + +template +inline void +Structure::copyFromRemote() +{ + if (d_buf) { + return; // already copied + } + + if (d_size < 512) { + d_buf = &d_footprintbuf[0]; + } else { + d_heapbuf.resize(d_size); + d_buf = &d_heapbuf[0]; + } + d_manager->copyMemoryFromProcess(d_addr, d_size, d_buf); +} + +template +template +inline remote_addr_t +Structure::getFieldRemoteAddress(FieldPointer OffsetsStruct::* field) const +{ + offset_t offset = (d_manager->offsets().get().*field).offset; + return d_addr + offset; +} + +template +template +inline const typename FieldPointer::Type& +Structure::getField(FieldPointer OffsetsStruct::* field) +{ + copyFromRemote(); + offset_t offset = (d_manager->offsets().get().*field).offset; + if (d_size < 0 || (size_t)d_size < sizeof(typename FieldPointer::Type) + || d_size - sizeof(typename FieldPointer::Type) < offset) + { + abort(); + } + auto address = d_buf + offset; + return *reinterpret_cast(address); +} + +} // namespace pystack diff --git a/src/pystack/_pystack/thread_builder.cpp b/src/pystack/_pystack/thread_builder.cpp new file mode 100644 index 00000000..51859bea --- /dev/null +++ b/src/pystack/_pystack/thread_builder.cpp @@ -0,0 +1,207 @@ +#include "thread_builder.h" + +#include + +#include "interpreter.h" +#include "logging.h" +#include "maps_parser.h" + +namespace pystack { + +// StackMethod flags (must match Python enum values) +enum StackMethodFlag { + METHOD_ELF_DATA = 1 << 0, + METHOD_SYMBOLS = 1 << 1, + METHOD_BSS = 1 << 2, + METHOD_ANONYMOUS_MAPS = 1 << 3, + METHOD_HEAP = 1 << 4, + METHOD_DEBUG_OFFSETS = 1 << 5, +}; + +std::vector +buildFrameStack(FrameObject* first_frame, bool resolve_locals) +{ + std::vector frames; + FrameObject* current_frame = first_frame; + + while (current_frame != nullptr) { + auto code = current_frame->Code(); + // Skip frames without code (shim frames) or with unreadable code ("???") + if (!code || code->Filename() == "???") { + auto prev = current_frame->PreviousFrame(); + current_frame = prev.get(); + continue; + } + + if (resolve_locals) { + current_frame->resolveLocalVariables(); + } + + PyFrameData frame_data; + frame_data.code.filename = code->Filename(); + frame_data.code.scope = code->Scope(); + frame_data.code.location = code->Location(); + frame_data.arguments = current_frame->Arguments(); + frame_data.locals = current_frame->Locals(); + frame_data.is_entry = current_frame->IsEntryFrame(); + frame_data.is_shim = current_frame->IsShim(); + + frames.push_back(std::move(frame_data)); + + auto prev = current_frame->PreviousFrame(); + current_frame = prev.get(); + } + + return frames; +} + +PyThreadData +buildPythonThread( + const std::shared_ptr& manager, + PyThread* thread, + pid_t pid, + bool add_native_traces, + bool resolve_locals, + int64_t interpreter_id) +{ + PyThreadData data; + data.tid = thread->Tid(); + data.name = getThreadName(pid, thread->Tid()); + + LOG(INFO) << "Constructing new Python thread with tid " << data.tid; + + if (add_native_traces) { + thread->populateNativeStackTrace(manager); + } + + auto first_frame = thread->FirstFrame(); + if (first_frame) { + data.frames = buildFrameStack(first_frame.get(), resolve_locals); + } + + const auto& native_frames = thread->NativeFrames(); + data.native_frames.assign(native_frames.rbegin(), native_frames.rend()); + + data.gil_status = static_cast(thread->isGilHolder()); + data.gc_status = static_cast(thread->isGCCollecting()); + data.interpreter_id = interpreter_id; + data.stack_anchor = thread->stackAnchor(); + + return data; +} + +PyThreadData +buildNativeThread(const std::shared_ptr& manager, pid_t pid, pid_t tid) +{ + PyThreadData data; + data.tid = tid; + data.name = getThreadName(pid, tid); + data.gil_status = 0; // NOT_HELD + data.gc_status = 0; // NOT_COLLECTING + data.interpreter_id = 0; // No Python stack for this thread means no interpreter + data.stack_anchor = 0; // and no stack anchor. + + LOG(INFO) << "Constructing new native thread with tid " << tid; + + Thread native_thread(pid, tid); + native_thread.populateNativeStackTrace(manager); + + const auto& native_frames = native_thread.NativeFrames(); + data.native_frames.assign(native_frames.rbegin(), native_frames.rend()); + + return data; +} + +std::vector +buildThreadsFromInterpreter( + const std::shared_ptr& manager, + remote_addr_t interpreter_head, + pid_t pid, + bool add_native_traces, + bool resolve_locals) +{ + LOG(INFO) << "Fetching Python threads"; + std::vector threads; + + auto thread = getThreadFromInterpreterState(manager, interpreter_head); + int64_t interpreter_id = InterpreterUtils::getInterpreterId(manager, interpreter_head); + PyThread* current_thread = thread.get(); + + while (current_thread != nullptr) { + threads.push_back(buildPythonThread( + manager, + current_thread, + pid, + add_native_traces, + resolve_locals, + interpreter_id)); + + auto next = current_thread->NextThread(); + current_thread = next.get(); + } + + return threads; +} + +remote_addr_t +getInterpreterStateAddr(AbstractProcessManager* manager, int method_flags) +{ + remote_addr_t head = 0; + + struct MethodInfo + { + int flag; + const char* name; + std::function func; + }; + + std::vector methods = { + {METHOD_DEBUG_OFFSETS, + "using debug offsets data", + [&]() { return manager->findInterpreterStateFromDebugOffsets(); }}, + {METHOD_ELF_DATA, + "using ELF data", + [&]() { return manager->findInterpreterStateFromElfData(); }}, + {METHOD_SYMBOLS, + "using symbols", + [&]() { return manager->findInterpreterStateFromSymbols(); }}, + {METHOD_BSS, "scanning the BSS", [&]() { return manager->scanBSS(); }}, + {METHOD_ANONYMOUS_MAPS, + "scanning all anonymous maps", + [&]() { return manager->scanAllAnonymousMaps(); }}, + {METHOD_HEAP, "scanning the heap", [&]() { return manager->scanHeap(); }}, + }; + + for (const auto& method : methods) { + if ((method_flags & method.flag) == 0) { + continue; + } + + try { + head = method.func(); + } catch (const std::exception& exc) { + LOG(WARNING) << "Unexpected error finding PyInterpreterState by " << method.name << ": " + << exc.what(); + continue; + } + + if (head != 0) { + LOG(INFO) << "PyInterpreterState found by " << method.name << " at address 0x" << std::hex + << head << std::dec; + return head; + } else { + LOG(INFO) << "Address of PyInterpreterState not found by " << method.name; + } + } + + LOG(INFO) << "Address of PyInterpreterState could not be found"; + return 0; +} + +std::vector +getThreadIds(const std::shared_ptr& manager) +{ + return manager->Tids(); +} + +} // namespace pystack diff --git a/src/pystack/_pystack/thread_builder.h b/src/pystack/_pystack/thread_builder.h new file mode 100644 index 00000000..562dee98 --- /dev/null +++ b/src/pystack/_pystack/thread_builder.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "maps_parser.h" +#include "native_frame.h" +#include "process.h" +#include "pycode.h" +#include "pyframe.h" +#include "pythread.h" + +namespace pystack { + +struct PyCodeData +{ + std::string filename; + std::string scope; + LocationInfo location; +}; + +struct PyFrameData +{ + PyCodeData code; + std::unordered_map arguments; + std::unordered_map locals; + bool is_entry; + bool is_shim; +}; + +struct PyThreadData +{ + int tid; + std::optional name; + std::vector frames; + std::vector native_frames; + int gil_status; // -1 = unknown, 0 = not held, 1 = held + int gc_status; // -1 = unknown, 0 = not collecting, 1 = collecting + int64_t interpreter_id; + remote_addr_t stack_anchor; +}; + +std::vector +buildThreadsFromInterpreter( + const std::shared_ptr& manager, + remote_addr_t interpreter_head, + pid_t pid, + bool add_native_traces, + bool resolve_locals); + +PyThreadData +buildPythonThread( + const std::shared_ptr& manager, + PyThread* thread, + pid_t pid, + bool add_native_traces, + bool resolve_locals, + int64_t interpreter_id); + +PyThreadData +buildNativeThread(const std::shared_ptr& manager, pid_t pid, pid_t tid); + +std::vector +buildFrameStack(FrameObject* first_frame, bool resolve_locals); + +remote_addr_t +getInterpreterStateAddr(AbstractProcessManager* manager, int method_flags); + +std::vector +getThreadIds(const std::shared_ptr& manager); + +} // namespace pystack diff --git a/src/pystack/_pystack/unwinder.cpp b/src/pystack/_pystack/unwinder.cpp index 6a32b078..0cbd2602 100644 --- a/src/pystack/_pystack/unwinder.cpp +++ b/src/pystack/_pystack/unwinder.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -81,10 +82,15 @@ frameCallback(Dwfl_Frame* state, void* arg) Dwarf_Addr pc; bool isActivation; if (!dwfl_frame_pc(state, &pc, &isActivation)) { - LOG(DEBUG) << "dwfl_frame_pc failed"; + int dwfl_err = dwfl_errno(); + LOG(DEBUG) << "dwfl_frame_pc failed: " << (dwfl_err ? dwfl_errmsg(dwfl_err) : "no error"); + LOG(DEBUG) << "Total frames gathered before failure: " << frames->size(); return -1; } + LOG(DEBUG) << std::hex << std::showbase << "frameCallback: pc=" << pc + << " isActivation=" << isActivation << " frame_count=" << std::dec << frames->size(); + std::optional stackPointer; // Unwinding through musl libc with elfutils can get stuck returning the // same PC in a loop forever. @@ -307,6 +313,8 @@ AbstractUnwinder::gatherFrames(const std::vector& frames) const if (!raw_symname) { LOG(DEBUG) << std::hex << std::showbase << "Non-inline symbol name could not be resolved @ " << pc; + // Add frame with unknown symbol rather than skipping it + native_frames.push_back({pc, "???", mod_name, 0, 0, mod_name}); continue; } @@ -344,6 +352,7 @@ AbstractUnwinder::dwarfModuleAddrDie(Dwarf_Addr pc_adjusted, Dwfl_Module* mod, D struct ModuleArg { + Dwfl* dwfl; const char* symbol; const char* modulename; remote_addr_t addr; @@ -374,10 +383,35 @@ module_callback( for (int i = 0; i < n_syms; i++) { const char* sname = dwfl_module_getsym_info(mod, i, &sym, &addr, nullptr, nullptr, nullptr); if (strcmp(sname, module_arg->symbol) == 0) { - module_arg->addr = addr; - LOG(INFO) << "Symbol '" << sname << "' found at address " << std::hex << std::showbase - << addr; - break; + Dwfl_Module* actual_module = dwfl_addrmodule(module_arg->dwfl, addr); + if (actual_module != mod) { + // Save this address if it's the first one we've seen, but keep looking for a match + // that resolves to the expected module. + if (!module_arg->addr) { + module_arg->addr = addr; + } + const char* actual_module_name = dwfl_module_info( + actual_module, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr); + LOG(INFO) << "Symbol '" << sname << "' found at address " << std::hex << std::showbase + << addr << " which falls in " + << (actual_module_name ? actual_module_name : "a non-file-backed mapping") + << " rather than the expected module " << module_arg->modulename + << " so we will keep looking for a better match"; + return DWARF_CB_OK; + } else { + // Otherwise, stop looking and use this match. + module_arg->addr = addr; + LOG(INFO) << "Symbol '" << sname << "' found at address " << std::hex << std::showbase + << addr; + return DWARF_CB_ABORT; + } } } return DWARF_CB_OK; @@ -387,8 +421,8 @@ remote_addr_t AbstractUnwinder::getAddressforSymbol(const std::string& symbol, const std::string& modulename) const { 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) { + ModuleArg arg = {Dwfl(), symbol.c_str(), modulename.c_str(), 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 @@ -480,24 +514,34 @@ thread_callback_for_frames(Dwfl_Thread* thread, void* arg) { auto* thread_arg = static_cast(arg); pid_t tid = dwfl_thread_tid(thread); + LOG(DEBUG) << "thread_callback_for_frames: checking thread tid=" << tid << " (looking for " + << thread_arg->tid << ")"; if (tid != thread_arg->tid) { return DWARF_CB_OK; } - switch (dwfl_thread_getframes(thread, frameCallback, (void*)(&(thread_arg->frames)))) { + LOG(DEBUG) << "thread_callback_for_frames: found matching thread, calling dwfl_thread_getframes"; + int result = dwfl_thread_getframes(thread, frameCallback, (void*)(&(thread_arg->frames))); + LOG(DEBUG) << "thread_callback_for_frames: dwfl_thread_getframes returned " << result << ", got " + << thread_arg->frames.size() << " frames"; + + switch (result) { case DWARF_CB_OK: case DWARF_CB_ABORT: break; - case -1: + case -1: { // This may or may not be an error, as it can signal the end of the stack // unwinding. + int dwfl_err = dwfl_errno(); + LOG(DEBUG) << "thread_callback_for_frames: dwfl error: " + << (dwfl_err ? dwfl_errmsg(dwfl_err) : "no error"); if (thread_arg->frames.empty()) { - int dwfl_err = dwfl_errno(); std::string error( dwfl_err ? dwfl_errmsg(dwfl_err) : "unwinding failed with no error reported"); throw UnwinderError("Unknown error happened when gathering thread frames: " + error); } break; + } default: throw UnwinderError("Unknown error happened when gathering thread frames"); } diff --git a/src/pystack/_pystack/unwinder.h b/src/pystack/_pystack/unwinder.h index ca9f08b5..42fb8c9e 100644 --- a/src/pystack/_pystack/unwinder.h +++ b/src/pystack/_pystack/unwinder.h @@ -18,7 +18,7 @@ class UnwinderError : public std::exception { public: explicit UnwinderError(std::string error) - : d_error(std::move(error)){}; + : d_error(std::move(error)) {}; [[nodiscard]] const char* what() const noexcept override { diff --git a/src/pystack/_pystack/version.cpp b/src/pystack/_pystack/version.cpp index 34fd2651..91ca5579 100644 --- a/src/pystack/_pystack/version.cpp +++ b/src/pystack/_pystack/version.cpp @@ -19,13 +19,15 @@ template constexpr py_code_v py_code() { - return {sizeof(T), - offsetof(T, co_filename), - offsetof(T, co_name), - offsetof(T, co_lnotab), - offsetof(T, co_firstlineno), - offsetof(T, co_argcount), - offsetof(T, co_varnames)}; + return { + sizeof(T), + {offsetof(T, co_filename)}, + {offsetof(T, co_name)}, + {offsetof(T, co_lnotab)}, + {offsetof(T, co_firstlineno)}, + {offsetof(T, co_argcount)}, + {offsetof(T, co_varnames)}, + }; } template @@ -33,52 +35,74 @@ constexpr py_code_v py_codev311() { return {sizeof(T), - offsetof(T, co_filename), - offsetof(T, co_name), - offsetof(T, co_linetable), - offsetof(T, co_firstlineno), - offsetof(T, co_argcount), - offsetof(T, co_localsplusnames), - offsetof(T, co_code_adaptive)}; + {offsetof(T, co_filename)}, + {offsetof(T, co_name)}, + {offsetof(T, co_linetable)}, + {offsetof(T, co_firstlineno)}, + {offsetof(T, co_argcount)}, + {offsetof(T, co_localsplusnames)}, + {offsetof(T, co_code_adaptive)}}; } template constexpr py_frame_v py_frame() { - return {sizeof(T), - offsetof(T, f_back), - offsetof(T, f_code), - offsetof(T, f_lasti), - 0, - offsetof(T, f_localsplus)}; + return { + sizeof(T), + {offsetof(T, f_back)}, + {offsetof(T, f_code)}, + {offsetof(T, f_lasti)}, + {0}, + {offsetof(T, f_localsplus)}, + }; } template constexpr py_frame_v py_framev311() { - return {sizeof(T), - offsetof(T, previous), - offsetof(T, f_code), - 0, - offsetof(T, prev_instr), - offsetof(T, localsplus), - offsetof(T, is_entry)}; + return { + sizeof(T), + {offsetof(T, previous)}, + {offsetof(T, f_code)}, + {0}, + {offsetof(T, prev_instr)}, + {offsetof(T, localsplus)}, + {offsetof(T, is_entry)}, + }; } template constexpr py_frame_v py_framev312() { - return {sizeof(T), - offsetof(T, previous), - offsetof(T, f_code), - 0, - offsetof(T, prev_instr), - offsetof(T, localsplus), - 0, - offsetof(T, owner)}; + return { + sizeof(T), + {offsetof(T, previous)}, + {offsetof(T, f_code)}, + {0}, + {offsetof(T, prev_instr)}, + {offsetof(T, localsplus)}, + {0}, + {offsetof(T, owner)}, + }; +} + +template +constexpr py_frame_v +py_framev314() +{ + return { + sizeof(T), + {offsetof(T, previous)}, + {offsetof(T, f_executable)}, + {0}, + {offsetof(T, instr_ptr)}, + {offsetof(T, localsplus)}, + {0}, + {offsetof(T, owner)}, + }; } template @@ -86,77 +110,124 @@ constexpr py_thread_v py_thead_h() { /* Hack. Python 3.3 and below don't have the prev field */ - return {sizeof(T), - offsetof(T, next), - offsetof(T, next), - offsetof(T, interp), - offsetof(T, frame), - offsetof(T, thread_id)}; + return { + sizeof(T), + {offsetof(T, next)}, + {offsetof(T, next)}, + {offsetof(T, interp)}, + {offsetof(T, frame)}, + {offsetof(T, thread_id)}, + }; } template constexpr py_thread_v py_thread() { - return {sizeof(T), - offsetof(T, prev), - offsetof(T, next), - offsetof(T, interp), - offsetof(T, frame), - offsetof(T, thread_id)}; + return { + sizeof(T), + {offsetof(T, prev)}, + {offsetof(T, next)}, + {offsetof(T, interp)}, + {offsetof(T, frame)}, + {offsetof(T, thread_id)}, + }; } template constexpr py_thread_v py_threadv311() { - return {sizeof(T), - offsetof(T, prev), - offsetof(T, next), - offsetof(T, interp), - offsetof(T, cframe), - offsetof(T, thread_id), - offsetof(T, native_thread_id)}; + return { + sizeof(T), + {offsetof(T, prev)}, + {offsetof(T, next)}, + {offsetof(T, interp)}, + {offsetof(T, cframe)}, + {offsetof(T, thread_id)}, + {offsetof(T, native_thread_id)}, + }; +} + +template +constexpr py_thread_v +py_threadv313() +{ + return { + sizeof(T), + {offsetof(T, prev)}, + {offsetof(T, next)}, + {offsetof(T, interp)}, + {offsetof(T, frame)}, + {offsetof(T, thread_id)}, + {offsetof(T, native_thread_id)}, + }; } template constexpr py_is_v py_is() { - return {sizeof(T), - offsetof(T, next), - offsetof(T, tstate_head), - offsetof(T, gc), - offsetof(T, modules), - offsetof(T, sysdict), - offsetof(T, builtins)}; + return { + sizeof(T), + {offsetof(T, next)}, + {offsetof(T, tstate_head)}, + {offsetof(T, gc)}, + {offsetof(T, modules)}, + {offsetof(T, sysdict)}, + {offsetof(T, builtins)}, + }; +} + +template +constexpr py_is_v +py_isv37() +{ + return { + sizeof(T), + {offsetof(T, next)}, + {offsetof(T, tstate_head)}, + {offsetof(T, gc)}, + {offsetof(T, modules)}, + {offsetof(T, sysdict)}, + {offsetof(T, builtins)}, + {0}, + {offsetof(T, id)}, + }; } template constexpr py_is_v py_isv311() { - return {sizeof(T), - offsetof(T, next), - offsetof(T, threads.head), - offsetof(T, gc), - offsetof(T, modules), - offsetof(T, sysdict), - offsetof(T, builtins)}; + return { + sizeof(T), + {offsetof(T, next)}, + {offsetof(T, threads.head)}, + {offsetof(T, gc)}, + {offsetof(T, modules)}, + {offsetof(T, sysdict)}, + {offsetof(T, builtins)}, + {0}, + {offsetof(T, id)}, + }; } template constexpr py_is_v py_isv312() { - return {sizeof(T), - offsetof(T, next), - offsetof(T, threads.head), - offsetof(T, gc), - offsetof(T, imports.modules), - offsetof(T, sysdict), - offsetof(T, builtins), - offsetof(T, ceval.gil)}; + return { + sizeof(T), + {offsetof(T, next)}, + {offsetof(T, threads.head)}, + {offsetof(T, gc)}, + {offsetof(T, imports.modules)}, + {offsetof(T, sysdict)}, + {offsetof(T, builtins)}, + {offsetof(T, ceval.gil)}, + {offsetof(T, id)}, + }; } template @@ -165,7 +236,7 @@ py_gc() { return { sizeof(T), - offsetof(T, collecting), + {offsetof(T, collecting)}, }; } @@ -175,7 +246,18 @@ py_cframe() { return { sizeof(T), - offsetof(T, current_frame), + {offsetof(T, current_frame)}, + }; +} + +template +constexpr py_gilruntimestate_v +py_gilruntimestate() +{ + return { + sizeof(T), + {offsetof(T, locked)}, + {offsetof(T, last_holder)}, }; } @@ -185,10 +267,10 @@ py_runtime() { return { sizeof(T), - offsetof(T, finalizing), - offsetof(T, interpreters.head), - offsetof(T, gc), - offsetof(T, gilstate.tstate_current._value), + {offsetof(T, finalizing)}, + {offsetof(T, interpreters.head)}, + {offsetof(T, gc)}, + {offsetof(T, gilstate.tstate_current._value)}, }; } @@ -198,8 +280,180 @@ py_runtimev312() { return { sizeof(T), - offsetof(T, finalizing), - offsetof(T, interpreters.head), + {offsetof(T, finalizing)}, + {offsetof(T, interpreters.head)}, + }; +} + +template +constexpr py_runtime_v +py_runtimev313() +{ + return { + sizeof(T), + {offsetof(T, finalizing)}, + {offsetof(T, interpreters.head)}, + {}, + {}, + {offsetof(T, debug_offsets.cookie)}, + {offsetof(T, debug_offsets.version)}, + {offsetof(T, debug_offsets.free_threaded)}, + {offsetof(T, debug_offsets.runtime_state.size)}, + {offsetof(T, debug_offsets.runtime_state.finalizing)}, + {offsetof(T, debug_offsets.runtime_state.interpreters_head)}, + {offsetof(T, debug_offsets.interpreter_state.size)}, + {offsetof(T, debug_offsets.interpreter_state.id)}, + {offsetof(T, debug_offsets.interpreter_state.next)}, + {offsetof(T, debug_offsets.interpreter_state.threads_head)}, + {offsetof(T, debug_offsets.interpreter_state.gc)}, + {offsetof(T, debug_offsets.interpreter_state.imports_modules)}, + {offsetof(T, debug_offsets.interpreter_state.sysdict)}, + {offsetof(T, debug_offsets.interpreter_state.builtins)}, + {offsetof(T, debug_offsets.interpreter_state.ceval_gil)}, + {offsetof(T, debug_offsets.interpreter_state.gil_runtime_state)}, + {offsetof(T, debug_offsets.interpreter_state.gil_runtime_state_enabled)}, + {offsetof(T, debug_offsets.interpreter_state.gil_runtime_state_locked)}, + {offsetof(T, debug_offsets.interpreter_state.gil_runtime_state_holder)}, + {offsetof(T, debug_offsets.thread_state.size)}, + {offsetof(T, debug_offsets.thread_state.prev)}, + {offsetof(T, debug_offsets.thread_state.next)}, + {offsetof(T, debug_offsets.thread_state.interp)}, + {offsetof(T, debug_offsets.thread_state.current_frame)}, + {offsetof(T, debug_offsets.thread_state.thread_id)}, + {offsetof(T, debug_offsets.thread_state.native_thread_id)}, + {offsetof(T, debug_offsets.thread_state.datastack_chunk)}, + {offsetof(T, debug_offsets.thread_state.status)}, + {offsetof(T, debug_offsets.interpreter_frame.size)}, + {offsetof(T, debug_offsets.interpreter_frame.previous)}, + {offsetof(T, debug_offsets.interpreter_frame.executable)}, + {offsetof(T, debug_offsets.interpreter_frame.instr_ptr)}, + {offsetof(T, debug_offsets.interpreter_frame.localsplus)}, + {offsetof(T, debug_offsets.interpreter_frame.owner)}, + {offsetof(T, debug_offsets.code_object.size)}, + {offsetof(T, debug_offsets.code_object.filename)}, + {offsetof(T, debug_offsets.code_object.name)}, + {offsetof(T, debug_offsets.code_object.qualname)}, + {offsetof(T, debug_offsets.code_object.linetable)}, + {offsetof(T, debug_offsets.code_object.firstlineno)}, + {offsetof(T, debug_offsets.code_object.argcount)}, + {offsetof(T, debug_offsets.code_object.localsplusnames)}, + {offsetof(T, debug_offsets.code_object.localspluskinds)}, + {offsetof(T, debug_offsets.code_object.co_code_adaptive)}, + {offsetof(T, debug_offsets.pyobject.size)}, + {offsetof(T, debug_offsets.pyobject.ob_type)}, + {offsetof(T, debug_offsets.type_object.size)}, + {offsetof(T, debug_offsets.type_object.tp_name)}, + {offsetof(T, debug_offsets.type_object.tp_repr)}, + {offsetof(T, debug_offsets.type_object.tp_flags)}, + {offsetof(T, debug_offsets.tuple_object.size)}, + {offsetof(T, debug_offsets.tuple_object.ob_item)}, + {offsetof(T, debug_offsets.tuple_object.ob_size)}, + {offsetof(T, debug_offsets.list_object.size)}, + {offsetof(T, debug_offsets.list_object.ob_item)}, + {offsetof(T, debug_offsets.list_object.ob_size)}, + {offsetof(T, debug_offsets.dict_object.size)}, + {offsetof(T, debug_offsets.dict_object.ma_keys)}, + {offsetof(T, debug_offsets.dict_object.ma_values)}, + {offsetof(T, debug_offsets.float_object.size)}, + {offsetof(T, debug_offsets.float_object.ob_fval)}, + {offsetof(T, debug_offsets.long_object.size)}, + {offsetof(T, debug_offsets.long_object.lv_tag)}, + {offsetof(T, debug_offsets.long_object.ob_digit)}, + {offsetof(T, debug_offsets.bytes_object.size)}, + {offsetof(T, debug_offsets.bytes_object.ob_size)}, + {offsetof(T, debug_offsets.bytes_object.ob_sval)}, + {offsetof(T, debug_offsets.unicode_object.size)}, + {offsetof(T, debug_offsets.unicode_object.state)}, + {offsetof(T, debug_offsets.unicode_object.length)}, + {offsetof(T, debug_offsets.unicode_object.asciiobject_size)}, + {offsetof(T, debug_offsets.gc.size)}, + {offsetof(T, debug_offsets.gc.collecting)}, + }; +} + +template +constexpr py_runtime_v +py_runtimev314() +{ + return { + sizeof(T), + {}, + {}, + {}, + {}, + {offsetof(T, debug_offsets.cookie)}, + {offsetof(T, debug_offsets.version)}, + {offsetof(T, debug_offsets.free_threaded)}, + {offsetof(T, debug_offsets.runtime_state.size)}, + {offsetof(T, debug_offsets.runtime_state.finalizing)}, + {offsetof(T, debug_offsets.runtime_state.interpreters_head)}, + {offsetof(T, debug_offsets.interpreter_state.size)}, + {offsetof(T, debug_offsets.interpreter_state.id)}, + {offsetof(T, debug_offsets.interpreter_state.next)}, + {offsetof(T, debug_offsets.interpreter_state.threads_head)}, + {offsetof(T, debug_offsets.interpreter_state.gc)}, + {offsetof(T, debug_offsets.interpreter_state.imports_modules)}, + {offsetof(T, debug_offsets.interpreter_state.sysdict)}, + {offsetof(T, debug_offsets.interpreter_state.builtins)}, + {offsetof(T, debug_offsets.interpreter_state.ceval_gil)}, + {offsetof(T, debug_offsets.interpreter_state.gil_runtime_state)}, + {offsetof(T, debug_offsets.interpreter_state.gil_runtime_state_enabled)}, + {offsetof(T, debug_offsets.interpreter_state.gil_runtime_state_locked)}, + {offsetof(T, debug_offsets.interpreter_state.gil_runtime_state_holder)}, + {offsetof(T, debug_offsets.thread_state.size)}, + {offsetof(T, debug_offsets.thread_state.prev)}, + {offsetof(T, debug_offsets.thread_state.next)}, + {offsetof(T, debug_offsets.thread_state.interp)}, + {offsetof(T, debug_offsets.thread_state.current_frame)}, + {offsetof(T, debug_offsets.thread_state.thread_id)}, + {offsetof(T, debug_offsets.thread_state.native_thread_id)}, + {offsetof(T, debug_offsets.thread_state.datastack_chunk)}, + {offsetof(T, debug_offsets.thread_state.status)}, + {offsetof(T, debug_offsets.interpreter_frame.size)}, + {offsetof(T, debug_offsets.interpreter_frame.previous)}, + {offsetof(T, debug_offsets.interpreter_frame.executable)}, + {offsetof(T, debug_offsets.interpreter_frame.instr_ptr)}, + {offsetof(T, debug_offsets.interpreter_frame.localsplus)}, + {offsetof(T, debug_offsets.interpreter_frame.owner)}, + {offsetof(T, debug_offsets.code_object.size)}, + {offsetof(T, debug_offsets.code_object.filename)}, + {offsetof(T, debug_offsets.code_object.name)}, + {offsetof(T, debug_offsets.code_object.qualname)}, + {offsetof(T, debug_offsets.code_object.linetable)}, + {offsetof(T, debug_offsets.code_object.firstlineno)}, + {offsetof(T, debug_offsets.code_object.argcount)}, + {offsetof(T, debug_offsets.code_object.localsplusnames)}, + {offsetof(T, debug_offsets.code_object.localspluskinds)}, + {offsetof(T, debug_offsets.code_object.co_code_adaptive)}, + {offsetof(T, debug_offsets.pyobject.size)}, + {offsetof(T, debug_offsets.pyobject.ob_type)}, + {offsetof(T, debug_offsets.type_object.size)}, + {offsetof(T, debug_offsets.type_object.tp_name)}, + {offsetof(T, debug_offsets.type_object.tp_repr)}, + {offsetof(T, debug_offsets.type_object.tp_flags)}, + {offsetof(T, debug_offsets.tuple_object.size)}, + {offsetof(T, debug_offsets.tuple_object.ob_item)}, + {offsetof(T, debug_offsets.tuple_object.ob_size)}, + {offsetof(T, debug_offsets.list_object.size)}, + {offsetof(T, debug_offsets.list_object.ob_item)}, + {offsetof(T, debug_offsets.list_object.ob_size)}, + {offsetof(T, debug_offsets.dict_object.size)}, + {offsetof(T, debug_offsets.dict_object.ma_keys)}, + {offsetof(T, debug_offsets.dict_object.ma_values)}, + {offsetof(T, debug_offsets.float_object.size)}, + {offsetof(T, debug_offsets.float_object.ob_fval)}, + {offsetof(T, debug_offsets.long_object.size)}, + {offsetof(T, debug_offsets.long_object.lv_tag)}, + {offsetof(T, debug_offsets.long_object.ob_digit)}, + {offsetof(T, debug_offsets.bytes_object.size)}, + {offsetof(T, debug_offsets.bytes_object.ob_size)}, + {offsetof(T, debug_offsets.bytes_object.ob_sval)}, + {offsetof(T, debug_offsets.unicode_object.size)}, + {offsetof(T, debug_offsets.unicode_object.state)}, + {offsetof(T, debug_offsets.unicode_object.length)}, + {offsetof(T, debug_offsets.unicode_object.asciiobject_size)}, + {offsetof(T, debug_offsets.gc.size)}, + {offsetof(T, debug_offsets.gc.collecting)}, }; } @@ -207,12 +461,145 @@ template constexpr py_type_v py_type() { - return {sizeof(T), offsetof(T, tp_name), offsetof(T, tp_repr), offsetof(T, tp_flags)}; + return {sizeof(T), {offsetof(T, tp_name)}, {offsetof(T, tp_repr)}, {offsetof(T, tp_flags)}}; +} + +template +constexpr py_object_v +py_object() +{ + return { + sizeof(T), + {offsetof(T, ob_type)}, + }; +} + +template +constexpr py_bytes_v +py_bytes() +{ + return { + sizeof(T), + {offsetof(T, ob_base.ob_size)}, + {offsetof(T, ob_sval)}, + }; +} + +template +constexpr py_unicode_v +py_unicode() +{ + return { + sizeof(T), + {offsetof(T, _base._base.state)}, + {offsetof(T, _base._base.length)}, + {offsetof(T, _base) + sizeof(T::_base._base)}, + }; +} + +template +constexpr py_tuple_v +py_tuple() +{ + return { + sizeof(T), + {offsetof(T, ob_base.ob_size)}, + {offsetof(T, ob_item)}, + }; +} + +template +constexpr py_list_v +py_list() +{ + return { + sizeof(T), + {offsetof(T, ob_base.ob_size)}, + {offsetof(T, ob_item)}, + }; +} + +template +constexpr py_dict_v +py_dict() +{ + return { + sizeof(T), + {offsetof(T, ma_keys)}, + {offsetof(T, ma_values)}, + }; +} + +template +constexpr py_dictkeys_v +py_dictkeys() +{ + return { + sizeof(T), + {offsetof(T, dk_size)}, + {}, + {offsetof(T, dk_nentries)}, + {offsetof(T, dk_indices)}, + }; +} + +template<> +constexpr py_dictkeys_v +py_dictkeys() +{ + return { + sizeof(Python3_11::PyDictKeysObject), + {offsetof(Python3_11::PyDictKeysObject, dk_log2_size)}, + {offsetof(Python3_11::PyDictKeysObject, dk_kind)}, + {offsetof(Python3_11::PyDictKeysObject, dk_nentries)}, + {offsetof(Python3_11::PyDictKeysObject, dk_indices)}, + }; +} + +template +constexpr py_dictvalues_v +py_dictvalues() +{ + return { + sizeof(T), + {offsetof(T, values)}, + }; +} + +template +constexpr py_float_v +py_float() +{ + return { + sizeof(T), + {offsetof(T, ob_fval)}, + }; +} + +template +constexpr py_long_v +py_long() +{ + return { + sizeof(T), + {offsetof(T, ob_base.ob_size)}, + {offsetof(T, ob_digit)}, + }; } // ---- Python 2 -------------------------------------------------------------- python_v python_v2 = { + py_tuple(), + py_list(), + {}, + {}, + {}, + py_float(), + py_long<_PyLongObject>(), + {}, + {}, + py_object(), py_type(), py_code(), py_frame(), @@ -223,6 +610,16 @@ python_v python_v2 = { // ---- Python 3.3 ------------------------------------------------------------ python_v python_v3_3 = { + py_tuple(), + py_list(), + py_dict(), + py_dictkeys(), + py_dictvalues(), + py_float(), + py_long<_PyLongObject>(), + py_bytes(), + py_unicode(), + py_object(), py_type(), py_code(), py_frame(), @@ -233,6 +630,16 @@ python_v python_v3_3 = { // ---- Python 3.4 ------------------------------------------------------------ python_v python_v3_4 = { + py_tuple(), + py_list(), + py_dict(), + py_dictkeys(), + py_dictvalues(), + py_float(), + py_long<_PyLongObject>(), + py_bytes(), + py_unicode(), + py_object(), py_type(), py_code(), py_frame(), @@ -243,6 +650,16 @@ python_v python_v3_4 = { // ---- Python 3.6 ------------------------------------------------------------ python_v python_v3_6 = { + py_tuple(), + py_list(), + py_dict(), + py_dictkeys(), + py_dictvalues(), + py_float(), + py_long<_PyLongObject>(), + py_bytes(), + py_unicode(), + py_object(), py_type(), py_code(), py_frame(), @@ -253,11 +670,21 @@ python_v python_v3_6 = { // ---- Python 3.7 ------------------------------------------------------------ python_v python_v3_7 = { + py_tuple(), + py_list(), + py_dict(), + py_dictkeys(), + py_dictvalues(), + py_float(), + py_long<_PyLongObject>(), + py_bytes(), + py_unicode(), + py_object(), py_type(), py_code(), py_frame(), py_thread(), - py_is(), + py_isv37(), py_runtime(), py_gc(), }; @@ -265,11 +692,21 @@ python_v python_v3_7 = { // ---- Python 3.8 ------------------------------------------------------------ python_v python_v3_8 = { + py_tuple(), + py_list(), + py_dict(), + py_dictkeys(), + py_dictvalues(), + py_float(), + py_long<_PyLongObject>(), + py_bytes(), + py_unicode(), + py_object(), py_type(), py_code(), py_frame(), py_thread(), - py_is(), + py_isv37(), py_runtime(), py_gc(), }; @@ -277,11 +714,21 @@ python_v python_v3_8 = { // ---- Python 3.9 ------------------------------------------------------------ python_v python_v3_9 = { + py_tuple(), + py_list(), + py_dict(), + py_dictkeys(), + py_dictvalues(), + py_float(), + py_long<_PyLongObject>(), + py_bytes(), + py_unicode(), + py_object(), py_type(), py_code(), py_frame(), py_thread(), - py_is(), + py_isv37(), py_runtime(), py_gc(), }; @@ -289,11 +736,21 @@ python_v python_v3_9 = { // ---- Python 3.10 ------------------------------------------------------------ python_v python_v3_10 = { + py_tuple(), + py_list(), + py_dict(), + py_dictkeys(), + py_dictvalues(), + py_float(), + py_long<_PyLongObject>(), + py_bytes(), + py_unicode(), + py_object(), py_type(), py_code(), py_frame(), py_thread(), - py_is(), + py_isv37(), py_runtime(), py_gc(), }; @@ -301,6 +758,16 @@ python_v python_v3_10 = { // ---- Python 3.11 ------------------------------------------------------------ python_v python_v3_11 = { + py_tuple(), + py_list(), + py_dict(), + py_dictkeys(), + py_dictvalues(), + py_float(), + py_long<_PyLongObject>(), + py_bytes(), + py_unicode(), + py_object(), py_type(), py_codev311(), py_framev311(), @@ -314,6 +781,16 @@ python_v python_v3_11 = { // ---- Python 3.12 ------------------------------------------------------------ python_v python_v3_12 = { + py_tuple(), + py_list(), + py_dict(), + py_dictkeys(), + py_dictvalues(), + py_float(), + py_long<_PyLongObject>(), + py_bytes(), + py_unicode(), + py_object(), py_type(), py_codev311(), py_framev312(), @@ -322,6 +799,81 @@ python_v python_v3_12 = { py_runtimev312(), py_gc(), py_cframe(), + py_gilruntimestate(), +}; + +// ---- Python 3.13 ------------------------------------------------------------ + +python_v python_v3_13 = { + py_tuple(), + py_list(), + py_dict(), + py_dictkeys(), + py_dictvalues(), + py_float(), + py_long<_PyLongObject>(), + py_bytes(), + py_unicode(), + py_object(), + py_type(), + py_codev311(), + py_framev312(), + py_threadv313(), + py_isv312(), + py_runtimev313(), + py_gc(), + py_cframe(), + py_gilruntimestate(), +}; + +// ---- Python 3.14 ------------------------------------------------------------ + +python_v python_v3_14 = { + {}, + {}, + {}, + py_dictkeys(), + py_dictvalues(), + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + py_runtimev314(), + {}, + py_cframe(), + {}, +}; + +// ----------------------------------------------------------------------------- + +// ---- Python 3.15 ------------------------------------------------------------ + +python_v python_v3_15 = { + {}, + {}, + {}, + py_dictkeys(), + py_dictvalues(), + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + py_runtimev314(), + {}, + py_cframe(), + {}, }; // ----------------------------------------------------------------------------- @@ -385,12 +937,20 @@ getCPythonOffsets(int major, int minor) case 11: return &python_v3_11; break; - + case 12: + return &python_v3_12; + break; + case 13: + return &python_v3_13; + break; + case 14: + return &python_v3_14; + break; default: warnAboutUnsuportedVersion(major, minor); // fallthrough to latest - case 12: - return &python_v3_12; + case 15: + return &python_v3_15; break; } break; diff --git a/src/pystack/_pystack/version.h b/src/pystack/_pystack/version.h index 846b390c..d9b2b2de 100644 --- a/src/pystack/_pystack/version.h +++ b/src/pystack/_pystack/version.h @@ -18,9 +18,78 @@ struct FieldOffset offset_t offset; }; +struct py_tuple_v +{ + ssize_t size; + FieldOffset o_ob_size; + FieldOffset o_ob_item; +}; + +struct py_list_v +{ + ssize_t size; + FieldOffset o_ob_size; + FieldOffset o_ob_item; +}; + +struct py_dict_v +{ + ssize_t size; + FieldOffset o_ma_keys; + FieldOffset o_ma_values; +}; + +struct py_dictkeys_v +{ + ssize_t size; + FieldOffset o_dk_size; + FieldOffset o_dk_kind; + FieldOffset o_dk_nentries; + FieldOffset o_dk_indices; +}; + +struct py_dictvalues_v +{ + ssize_t size; + FieldOffset o_values; +}; + +struct py_float_v +{ + ssize_t size; + FieldOffset o_ob_fval; +}; + +struct py_long_v +{ + ssize_t size; + FieldOffset o_ob_size; + FieldOffset o_ob_digit; +}; + +struct py_bytes_v +{ + ssize_t size; + FieldOffset o_ob_size; + FieldOffset o_ob_sval; +}; + +struct py_unicode_v +{ + ssize_t size; + FieldOffset o_state; + FieldOffset o_length; + FieldOffset o_ascii; +}; + +struct py_object_v +{ + ssize_t size; + FieldOffset o_ob_type; +}; + struct py_code_v { - typedef PyCodeObject Structure; ssize_t size; FieldOffset o_filename; FieldOffset o_name; @@ -33,7 +102,6 @@ struct py_code_v struct py_frame_v { - typedef PyFrameObject Structure; ssize_t size; FieldOffset o_back; FieldOffset o_code; @@ -46,7 +114,6 @@ struct py_frame_v struct py_thread_v { - typedef PyThreadState Structure; ssize_t size; FieldOffset o_prev; FieldOffset o_next; @@ -58,17 +125,105 @@ struct py_thread_v struct py_runtime_v { - typedef PyRuntimeState Structure; ssize_t size; FieldOffset o_finalizing; FieldOffset o_interp_head; - FieldOffset o_gc; + FieldOffset o_gc; // Using char because we can only use the offset, + // as the size and members change between versions FieldOffset o_tstate_current; + + FieldOffset o_dbg_off_cookie; + FieldOffset o_dbg_off_py_version_hex; + FieldOffset o_dbg_off_free_threaded; + + FieldOffset o_dbg_off_runtime_state_struct_size; + FieldOffset o_dbg_off_runtime_state_finalizing; + FieldOffset o_dbg_off_runtime_state_interpreters_head; + + FieldOffset o_dbg_off_interpreter_state_struct_size; + FieldOffset o_dbg_off_interpreter_state_id; + FieldOffset o_dbg_off_interpreter_state_next; + FieldOffset o_dbg_off_interpreter_state_threads_head; + FieldOffset o_dbg_off_interpreter_state_gc; + FieldOffset o_dbg_off_interpreter_state_imports_modules; + FieldOffset o_dbg_off_interpreter_state_sysdict; + FieldOffset o_dbg_off_interpreter_state_builtins; + FieldOffset o_dbg_off_interpreter_state_ceval_gil; + FieldOffset o_dbg_off_interpreter_state_gil_runtime_state; + FieldOffset o_dbg_off_interpreter_state_gil_runtime_state_enabled; + FieldOffset o_dbg_off_interpreter_state_gil_runtime_state_locked; + FieldOffset o_dbg_off_interpreter_state_gil_runtime_state_holder; + + FieldOffset o_dbg_off_thread_state_struct_size; + FieldOffset o_dbg_off_thread_state_prev; + FieldOffset o_dbg_off_thread_state_next; + FieldOffset o_dbg_off_thread_state_interp; + FieldOffset o_dbg_off_thread_state_current_frame; + FieldOffset o_dbg_off_thread_state_thread_id; + FieldOffset o_dbg_off_thread_state_native_thread_id; + FieldOffset o_dbg_off_thread_state_datastack_chunk; + FieldOffset o_dbg_off_thread_state_status; + + FieldOffset o_dbg_off_interpreter_frame_struct_size; + FieldOffset o_dbg_off_interpreter_frame_previous; + FieldOffset o_dbg_off_interpreter_frame_executable; + FieldOffset o_dbg_off_interpreter_frame_instr_ptr; + FieldOffset o_dbg_off_interpreter_frame_localsplus; + FieldOffset o_dbg_off_interpreter_frame_owner; + + FieldOffset o_dbg_off_code_object_struct_size; + FieldOffset o_dbg_off_code_object_filename; + FieldOffset o_dbg_off_code_object_name; + FieldOffset o_dbg_off_code_object_qualname; + FieldOffset o_dbg_off_code_object_linetable; + FieldOffset o_dbg_off_code_object_firstlineno; + FieldOffset o_dbg_off_code_object_argcount; + FieldOffset o_dbg_off_code_object_localsplusnames; + FieldOffset o_dbg_off_code_object_localspluskinds; + FieldOffset o_dbg_off_code_object_co_code_adaptive; + + FieldOffset o_dbg_off_pyobject_struct_size; + FieldOffset o_dbg_off_pyobject_ob_type; + + FieldOffset o_dbg_off_type_object_struct_size; + FieldOffset o_dbg_off_type_object_tp_name; + FieldOffset o_dbg_off_type_object_tp_repr; + FieldOffset o_dbg_off_type_object_tp_flags; + + FieldOffset o_dbg_off_tuple_object_struct_size; + FieldOffset o_dbg_off_tuple_object_ob_item; + FieldOffset o_dbg_off_tuple_object_ob_size; + + FieldOffset o_dbg_off_list_object_struct_size; + FieldOffset o_dbg_off_list_object_ob_item; + FieldOffset o_dbg_off_list_object_ob_size; + + FieldOffset o_dbg_off_dict_object_struct_size; + FieldOffset o_dbg_off_dict_object_ma_keys; + FieldOffset o_dbg_off_dict_object_ma_values; + + FieldOffset o_dbg_off_float_object_struct_size; + FieldOffset o_dbg_off_float_object_ob_fval; + + FieldOffset o_dbg_off_long_object_struct_size; + FieldOffset o_dbg_off_long_object_lv_tag; + FieldOffset o_dbg_off_long_object_ob_digit; + + FieldOffset o_dbg_off_bytes_object_struct_size; + FieldOffset o_dbg_off_bytes_object_ob_size; + FieldOffset o_dbg_off_bytes_object_ob_sval; + + FieldOffset o_dbg_off_unicode_object_struct_size; + FieldOffset o_dbg_off_unicode_object_state; + FieldOffset o_dbg_off_unicode_object_length; + FieldOffset o_dbg_off_unicode_object_asciiobject_size; + + FieldOffset o_dbg_off_gc_struct_size; + FieldOffset o_dbg_off_gc_collecting; }; struct py_type_v { - typedef PyTypeObject Structure; ssize_t size; FieldOffset o_tp_name; FieldOffset o_tp_repr; @@ -77,33 +232,49 @@ struct py_type_v struct py_is_v { - typedef PyInterpreterState Structure; ssize_t size; FieldOffset o_next; FieldOffset o_tstate_head; - FieldOffset o_gc; + FieldOffset o_gc; // Using char because we can only use the offset, + // as the size and members change between versions FieldOffset o_modules; FieldOffset o_sysdict; FieldOffset o_builtins; FieldOffset o_gil_runtime_state; + FieldOffset o_id; }; struct py_gc_v { - typedef GCRuntimeState Structure; ssize_t size; FieldOffset o_collecting; }; struct py_cframe_v { - typedef CFrame Structure; ssize_t size; FieldOffset current_frame; }; +struct py_gilruntimestate_v +{ + ssize_t size; + FieldOffset o_locked; + FieldOffset o_last_holder; +}; + struct python_v { + py_tuple_v py_tuple; + py_list_v py_list; + py_dict_v py_dict; + py_dictkeys_v py_dictkeys; + py_dictvalues_v py_dictvalues; + py_float_v py_float; + py_long_v py_long; + py_bytes_v py_bytes; + py_unicode_v py_unicode; + py_object_v py_object; py_type_v py_type; py_code_v py_code; py_frame_v py_frame; @@ -112,6 +283,7 @@ struct python_v py_runtime_v py_runtime; py_gc_v py_gc; py_cframe_v py_cframe; + py_gilruntimestate_v py_gilruntimestate; template inline const T& get() const; @@ -124,6 +296,16 @@ struct python_v return T; \ } +define_python_v_get_specialization(py_tuple); +define_python_v_get_specialization(py_list); +define_python_v_get_specialization(py_dict); +define_python_v_get_specialization(py_dictkeys); +define_python_v_get_specialization(py_dictvalues); +define_python_v_get_specialization(py_float); +define_python_v_get_specialization(py_long); +define_python_v_get_specialization(py_bytes); +define_python_v_get_specialization(py_unicode); +define_python_v_get_specialization(py_object); define_python_v_get_specialization(py_type); define_python_v_get_specialization(py_code); define_python_v_get_specialization(py_frame); @@ -132,6 +314,7 @@ define_python_v_get_specialization(py_is); define_python_v_get_specialization(py_runtime); define_python_v_get_specialization(py_gc); define_python_v_get_specialization(py_cframe); +define_python_v_get_specialization(py_gilruntimestate); #undef define_python_v_get_specialization diff --git a/src/pystack/_pystack/version_detector.cpp b/src/pystack/_pystack/version_detector.cpp new file mode 100644 index 00000000..8db20299 --- /dev/null +++ b/src/pystack/_pystack/version_detector.cpp @@ -0,0 +1,165 @@ +#include "version_detector.h" + +#include +#include +#include +#include +#include + +#include "logging.h" + +namespace pystack { + +namespace fs = std::filesystem; + +// Regex patterns for version detection +// Matches: "3.8.10 (default, May 26 2023, 14:05:08)" or similar version strings in BSS +static const std::regex BSS_VERSION_REGEXP( + R"(((2|3)\.(\d+)\.(\d{1,2}))((a|b|c|rc)\d{1,2})?\+?(?: (?:experimental )?free-threading build)? (\(.{1,64}\)))"); + +// Matches: python3.8, python3.10, etc. +static const std::regex BINARY_REGEXP(R"(python(\d+)\.(\d+).*)", std::regex_constants::icase); + +// Matches: libpython3.8.so, libpython3.10.so.1.0, etc. +static const std::regex LIBPYTHON_REGEXP(R"(.*libpython(\d+)\.(\d+).*)", std::regex_constants::icase); + +static std::optional +scanProcessBssForVersion(pid_t pid, const VirtualMap& bss, AbstractRemoteMemoryManager* manager) +{ + if (!manager) { + return std::nullopt; + } + + size_t size = bss.Size(); + std::vector memory(size); + + try { + ssize_t bytes_read = manager->copyMemoryFromProcess(bss.Start(), size, memory.data()); + if (bytes_read < 0) { + return std::nullopt; + } + } catch (...) { + return std::nullopt; + } + + std::string memory_str(memory.begin(), memory.end()); + std::smatch match; + if (std::regex_search(memory_str, match, BSS_VERSION_REGEXP)) { + int major = std::stoi(match[2].str()); + int minor = std::stoi(match[3].str()); + return PythonVersion(major, minor); + } + + return std::nullopt; +} + +static std::optional +scanCoreBssForVersion(const std::string& corefile, const VirtualMap& bss) +{ + std::ifstream file(corefile, std::ios::binary); + if (!file.is_open()) { + return std::nullopt; + } + + file.seekg(bss.Offset()); + if (!file.good()) { + return std::nullopt; + } + + size_t size = bss.Size(); + std::vector data(size); + file.read(data.data(), size); + if (!file.good() && !file.eof()) { + return std::nullopt; + } + + std::string data_str(data.begin(), data.end()); + std::smatch match; + if (std::regex_search(data_str, match, BSS_VERSION_REGEXP)) { + int major = std::stoi(match[2].str()); + int minor = std::stoi(match[3].str()); + return PythonVersion(major, minor); + } + + return std::nullopt; +} + +static std::optional +inferVersionFromPath(const std::string& path) +{ + std::string filename = fs::path(path).filename().string(); + + std::smatch match; + if (std::regex_match(filename, match, LIBPYTHON_REGEXP)) { + int major = std::stoi(match[1].str()); + int minor = std::stoi(match[2].str()); + LOG(INFO) << "Version inferred from libpython path: " << major << "." << minor; + return PythonVersion(major, minor); + } + + if (std::regex_match(filename, match, BINARY_REGEXP)) { + int major = std::stoi(match[1].str()); + int minor = std::stoi(match[2].str()); + LOG(INFO) << "Version inferred from binary path: " << major << "." << minor; + return PythonVersion(major, minor); + } + + return std::nullopt; +} + +static PythonVersion +getVersionFromMapInfo(const ProcessMemoryMapInfo& mapinfo) +{ + if (mapinfo.libpython && !mapinfo.libpython->Path().empty()) { + LOG(INFO) << "Trying to extract version from filename: " << mapinfo.libpython->Path(); + auto version = inferVersionFromPath(mapinfo.libpython->Path()); + if (version) { + return *version; + } + } + + if (!mapinfo.python.Path().empty()) { + LOG(INFO) << "Trying to extract version from filename: " << mapinfo.python.Path(); + auto version = inferVersionFromPath(mapinfo.python.Path()); + if (version) { + return *version; + } + } + + throw std::runtime_error("Could not determine python version from " + mapinfo.python.Path()); +} + +PythonVersion +getVersionForProcess( + pid_t pid, + const ProcessMemoryMapInfo& mapinfo, + AbstractRemoteMemoryManager* manager) +{ + if (mapinfo.bss) { + auto version = scanProcessBssForVersion(pid, *mapinfo.bss, manager); + if (version) { + LOG(INFO) << "Version found by scanning the bss section: " << version->first << "." + << version->second; + return *version; + } + } + + return getVersionFromMapInfo(mapinfo); +} + +PythonVersion +getVersionForCore(const std::string& corefile, const ProcessMemoryMapInfo& mapinfo) +{ + if (mapinfo.bss) { + auto version = scanCoreBssForVersion(corefile, *mapinfo.bss); + if (version) { + LOG(INFO) << "Version found by scanning the bss section: " << version->first << "." + << version->second; + return *version; + } + } + + return getVersionFromMapInfo(mapinfo); +} + +} // namespace pystack diff --git a/src/pystack/_pystack/version_detector.h b/src/pystack/_pystack/version_detector.h new file mode 100644 index 00000000..403b8675 --- /dev/null +++ b/src/pystack/_pystack/version_detector.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +#include "maps_parser.h" +#include "mem.h" + +namespace pystack { + +using PythonVersion = std::pair; + +PythonVersion +getVersionForProcess( + pid_t pid, + const ProcessMemoryMapInfo& mapinfo, + AbstractRemoteMemoryManager* manager); + +PythonVersion +getVersionForCore(const std::string& corefile, const ProcessMemoryMapInfo& mapinfo); + +} // namespace pystack diff --git a/src/pystack/_version.py b/src/pystack/_version.py index 67bc602a..1db13ec2 100644 --- a/src/pystack/_version.py +++ b/src/pystack/_version.py @@ -1 +1 @@ -__version__ = "1.3.0" +__version__ = "1.7.2" diff --git a/src/pystack/errors.py b/src/pystack/errors.py index 5bbd58d3..d08a0792 100644 --- a/src/pystack/errors.py +++ b/src/pystack/errors.py @@ -1,6 +1,11 @@ import pathlib +from functools import wraps from typing import Any +from typing import Callable from typing import Optional +from typing import TypeVar + +F = TypeVar("F", bound=Callable[..., Any]) DETECTED_EXECUTABLE_NOT_FOUND_TEXT = """\ The executable that was automatically located by pystack doesn't exist. @@ -81,8 +86,7 @@ """ -class PystackError(Exception): - ... +class PystackError(Exception): ... class EngineError(PystackError): @@ -102,12 +106,10 @@ def __str__(self) -> str: return f"Engine error: {message}" -class ProcessNotFound(PystackError, ProcessLookupError): - ... +class ProcessNotFound(PystackError, ProcessLookupError): ... -class InvalidPythonProcess(PystackError): - ... +class InvalidPythonProcess(PystackError): ... class CoreExecutableNotFound(PystackError): @@ -128,3 +130,19 @@ class InvalidExecutable(PystackError): class MissingExecutableMaps(PystackError): HELP_TEXT = MISSING_EXECUTABLE_MAPS_HELP_TEXT + + +def intercept_runtime_errors() -> Callable[[F], F]: + """Decorator that converts RuntimeError to EngineError.""" + + def decorator(func: F) -> F: + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return func(*args, **kwargs) + except RuntimeError as e: + raise EngineError(str(e)) from e + + return wrapper # type: ignore[return-value] + + return decorator diff --git a/src/pystack/maps.py b/src/pystack/maps.py index eda70bcc..419ca7e7 100644 --- a/src/pystack/maps.py +++ b/src/pystack/maps.py @@ -1,46 +1,18 @@ -import collections +"""Memory map data classes for process analysis. + +This module provides data classes for representing memory maps. +The actual parsing is done in C++. +""" + import dataclasses -import logging -import os -import re from pathlib import Path -from typing import Any -from typing import Dict -from typing import Iterable -from typing import List from typing import Optional -from .errors import MissingExecutableMaps -from .errors import ProcessNotFound -from .errors import PystackError - -LOGGER = logging.getLogger(__file__) - -MAPS_REGEXP = re.compile( - r""" - (?P[\da-f]+) - - - (?P[\da-f]+) - \s - (?P....) - \s - (?P[\da-f]+) - \s - (?P[\da-f][\da-f]+:[\da-f][\da-f]+) - \s - (?P\d+) - \s* - (?P.+)? - $ - """, - re.VERBOSE, -) - -RawCoreMapList = List[Dict[str, Any]] - @dataclasses.dataclass(frozen=True, eq=True) class VirtualMap: + """Represents a memory-mapped region in a process's virtual address space.""" + start: int end: int filesize: int @@ -51,22 +23,28 @@ class VirtualMap: path: Optional[Path] def contains(self, addr: int) -> bool: + """Check if the given address is within this memory map.""" return self.start <= addr < self.end def is_executable(self) -> bool: + """Check if this memory region is executable.""" return "x" in self.flags def is_readable(self) -> bool: + """Check if this memory region is readable.""" return "r" in self.flags def is_writable(self) -> bool: + """Check if this memory region is writable.""" return "w" in self.flags def is_private(self) -> bool: + """Check if this memory region is private (copy-on-write).""" return "p" in self.flags @property def size(self) -> int: + """Return the size of this memory region.""" return self.end - self.start def __repr__(self) -> str: @@ -84,244 +62,18 @@ def __repr__(self) -> str: @dataclasses.dataclass class MemoryRange: + """Represents a range of memory addresses.""" + min_addr: int max_addr: int @dataclasses.dataclass class MemoryMapInformation: + """Container for memory map information needed for process analysis.""" + memory: MemoryRange heap: Optional[VirtualMap] bss: Optional[VirtualMap] python: VirtualMap libpython: Optional[VirtualMap] - - -def _read_maps(pid: int) -> List[str]: - try: - with open(f"/proc/{pid}/maps") as maps: - return maps.readlines() - except FileNotFoundError: - raise ProcessNotFound(f"No such process id: {pid}") from None - - -def generate_maps_for_process(pid: int) -> Iterable[VirtualMap]: - proc_maps_lines = _read_maps(pid) - for index, line in enumerate(proc_maps_lines): - line = line.rstrip("\n") - match = MAPS_REGEXP.match(line) - if not match: - LOGGER.debug("Line %r cannot be recognized!", line) - continue - - path = match.group("pathname") - yield VirtualMap( - start=int(match.group("start"), 16), - end=int(match.group("end"), 16), - filesize=int(match.group("end"), 16) - int(match.group("start"), 16), - offset=int(match.group("offset"), 16), - device=match.group("dev"), - flags=match.group("permissions"), - inode=int(match.group("inode")), - path=Path(path) if path else None, - ) - - -def generate_maps_from_core_data( - mapped_files: RawCoreMapList, memory_maps: RawCoreMapList -) -> Iterable[VirtualMap]: - memory_map_ranges = {(map["start"], map["end"]) for map in memory_maps} - missing_mapped_files = [ - map - for map in mapped_files - if (map["start"], map["end"]) not in memory_map_ranges - ] - - all_maps: RawCoreMapList = sorted( - memory_maps + missing_mapped_files, key=lambda map: map["start"] - ) - - # Some paths in the mapped files can be absolute, but we need to work with the canonical - # paths that the linker reported, so we need to "unresolve" those path back to whatever - # the memory math paths are so we can properly group then together. For example, the map - # for the interpreter may be "/usr/bin/python" in the mapped files and "/venv/bin/python" - # in the memory maps. - missing_map_paths = { - Path(map["path"]) for map in missing_mapped_files if map is not None - } - file_maps = {} - for map in memory_maps: - if not map["path"]: - continue - the_path = Path(map["path"]) - resolved_path = the_path.resolve() - if resolved_path in missing_map_paths: - file_maps[resolved_path] = the_path - - for data_elem in all_maps: - path = Path(data_elem["path"]) if data_elem["path"] else None - if path is not None: - path = file_maps.get(path, path) - - yield VirtualMap( - start=data_elem["start"], - end=data_elem["end"], - filesize=data_elem["filesize"], - offset=data_elem["offset"], - device=data_elem["device"], - flags=data_elem["flags"], - inode=data_elem["inode"], - path=path, - ) - - -def parse_maps_file(pid: int, all_maps: Iterable[VirtualMap]) -> MemoryMapInformation: - binary_name = Path(os.readlink(f"/proc/{pid}/exe")) - return parse_maps_file_for_binary(binary_name, all_maps) - - -def _get_base_map(binary_maps: List[VirtualMap]) -> VirtualMap: - maybe_map = next( - (map for map in binary_maps if map.path is not None), - None, - ) - if maybe_map is not None: - return maybe_map - first_map, *_ = binary_maps - return first_map - - -def _get_bss(elf_maps: List[VirtualMap], load_point: int) -> Optional[VirtualMap]: - binary_map = _get_base_map(elf_maps) - if not binary_map or not binary_map.path: - return None - try: - from ._pystack import get_bss_info - except ImportError: # pragma: no cover - return None - bss_info = get_bss_info(binary_map.path) - if not bss_info: - return None - start = load_point + bss_info["corrected_addr"] - LOGGER.info( - "Determined exact addr of .bss section: %s (%s + %s)", - hex(start), - hex(load_point), - hex(bss_info["corrected_addr"]), - ) - offset = 0 - - # Calculate the offset based on the mapped files. The offset in core files - # is only present in the core (and not in the original ELF) so this - # operation allows us to correlate the bss section with some memory location - # within the core file. - first_matching_map = next((map for map in elf_maps if map.contains(start)), None) - if first_matching_map is None: - return None - - offset = first_matching_map.offset + (start - first_matching_map.start) - - bss = VirtualMap( - start=start, - end=start + bss_info["size"], - filesize=bss_info["size"], - offset=offset, - device="", - flags="", - inode=0, - path=None, - ) - return bss - - -def parse_maps_file_for_binary( - binary_name: Path, - all_maps_iter: Iterable[VirtualMap], - load_point_by_module: Optional[Dict[str, int]] = None, -) -> MemoryMapInformation: - min_addr = float("inf") - max_addr = 0 - maps_by_library: Dict[str, List[VirtualMap]] = collections.defaultdict(list) - current_lib = "" - all_maps = tuple(all_maps_iter) - - if load_point_by_module is None: - load_point_by_module = collections.defaultdict(lambda: 2**64) - for memory_range in all_maps: - if memory_range.path is not None: - load_point_by_module[memory_range.path.name] = min( - memory_range.start, - load_point_by_module[memory_range.path.name], - ) - - for memory_range in all_maps: - current_lib = ( - memory_range.path.name if memory_range.path is not None else current_lib - ) - maps_by_library[current_lib].append(memory_range) - - if memory_range.path is None or not memory_range.path.name.startswith("[v"): - min_addr = min(min_addr, memory_range.start) - max_addr = max(max_addr, memory_range.end) - maps_by_library = dict(maps_by_library) - - python = libpython = bss = heap = None - try: - binary_maps = maps_by_library[binary_name.name] - python = _get_base_map(binary_maps) - except KeyError: - LOGGER.debug("Unable to find maps for %r in %r", binary_name, maps_by_library) - available_maps = { - str(map.path) - for map in all_maps - if map.path is not None and ".so" not in map.path.name - } - LOGGER.debug("Available executable maps: %s", ", ".join(available_maps)) - if available_maps: - maps_txt = ", ".join(available_maps) - msg = f"These are the available executable memory maps: {maps_txt}" - else: - msg = "There are no available executable maps with known paths." - raise MissingExecutableMaps( - f"Unable to find maps for the executable {binary_name}. " + msg - ) - LOGGER.info("python binary first map found: %r", python) - - libpython_binaries = [lib for lib in maps_by_library if "libpython" in lib] - if len(libpython_binaries) > 1: - raise PystackError( - f"Unexpectedly found multiple libpython in process: {libpython_binaries}" - ) - elif len(libpython_binaries) == 1: - libpython_name = libpython_binaries[0] - libpython_maps = maps_by_library[libpython_name] - load_point = load_point_by_module[libpython_name] - elf_maps = libpython_maps - libpython = _get_base_map(libpython_maps) - LOGGER.info("%r first map found: %r", libpython_name, libpython) - else: - LOGGER.info("Process does not have a libpython.so, reading from binary") - elf_maps = binary_maps - libpython = None - load_point = load_point_by_module[binary_name.name] - - heap_maps = maps_by_library.get("[heap]") - if heap_maps is not None: - *_, heap = [m for m in heap_maps if getattr(m.path, "name", None) == "[heap]"] - LOGGER.info("Heap map found: %r", heap) - - bss = _get_bss(elf_maps, load_point) - if bss is None: - bss = ( - next( - (map for map in elf_maps if map.path is None and map.is_readable()), - None, - ) - if elf_maps - else None - ) - if bss: - LOGGER.info("bss map found: %r", bss) - - memory = MemoryRange(min_addr=int(min_addr), max_addr=int(max_addr)) - return MemoryMapInformation(memory, heap, bss, python, libpython) diff --git a/src/pystack/process.py b/src/pystack/process.py index c5fb1370..467ca4e1 100644 --- a/src/pystack/process.py +++ b/src/pystack/process.py @@ -1,131 +1,58 @@ -import logging -import pathlib -import re -import subprocess -from typing import Optional -from typing import Tuple - -from .errors import InvalidPythonProcess -from .maps import MemoryMapInformation -from .maps import VirtualMap - -VERSION_REGEXP = re.compile(r"Python (?P\d+)\.(?P\d+).*", re.IGNORECASE) - -BINARY_REGEXP = re.compile(r"python(?P\d+)\.(?P\d+).*", re.IGNORECASE) - -LIBPYTHON_REGEXP = re.compile( - r".*libpython(?P\d+)\.(?P\d+).*", re.IGNORECASE -) - -BSS_VERSION_REGEXP = re.compile( - rb"((2|3)\.(\d+)\.(\d{1,2}))((a|b|c|rc)\d{1,2})?\+? (\(.{1,64}\))" -) - -LOGGER = logging.getLogger(__file__) - - -def scan_process_bss_for_python_version( - pid: int, bss: VirtualMap -) -> Optional[Tuple[int, int]]: - # Lazy import _pystack to overcome a circular-import - # (we really don't want a new extension just for this) :( - try: - from pystack._pystack import copy_memory_from_address - except ImportError: # pragma: no cover - return None - memory = copy_memory_from_address(pid, bss.start, bss.size) - match = BSS_VERSION_REGEXP.findall(memory) - if not match: - return None - ((_, major, minor, patch, *_),) = match - return int(major), int(minor) +"""Process utility functions. +This module provides utility functions for checking file types +and decompressing gzip files. +""" -def scan_core_bss_for_python_version( - corefile: pathlib.Path, bss: VirtualMap -) -> Optional[Tuple[int, int]]: - with open(corefile, "rb") as the_corefile: - the_corefile.seek(bss.offset) - data = the_corefile.read(bss.size) - match = next(BSS_VERSION_REGEXP.finditer(data), None) - if not match: - return None - _, major, minor, patch, *_ = match.groups() - return int(major), int(minor) - - -def _get_python_version_from_map_information( - mapinfo: MemoryMapInformation, -) -> Tuple[int, int]: - match = None - assert mapinfo.python.path is not None - if mapinfo.libpython: - assert mapinfo.libpython.path is not None - LOGGER.info( - "Trying to extract version from filename: %s", mapinfo.libpython.path.name - ) - match = LIBPYTHON_REGEXP.match(mapinfo.libpython.path.name) - else: - LOGGER.info( - "Trying to extract version from filename: %s", mapinfo.python.path.name - ) - match = BINARY_REGEXP.match(mapinfo.python.path.name) - if match is None: - LOGGER.info( - "Could not find version by looking at library or binary path: " - "Trying to get it from running python --version" - ) - output = subprocess.check_output( - [mapinfo.python.path, "--version"], text=True, stderr=subprocess.STDOUT - ) - match = VERSION_REGEXP.match(output) - if not match: - raise InvalidPythonProcess( - f"Could not determine python version from {mapinfo.python.path}" - ) - major = match.group("major") - minor = match.group("minor") - LOGGER.info("Python version determined: %s.%s", major, minor) - return int(major), int(minor) - - -def get_python_version_for_process( - pid: int, mapinfo: MemoryMapInformation -) -> Tuple[int, int]: - if mapinfo.bss is not None: - version_from_bss = scan_process_bss_for_python_version(pid, mapinfo.bss) - if version_from_bss is not None: - LOGGER.info( - "Version found by scanning the bss section: %d.%d", *version_from_bss - ) - return version_from_bss - - return _get_python_version_from_map_information(mapinfo) - - -def get_python_version_for_core( - corefile: pathlib.Path, executable: pathlib.Path, mapinfo: MemoryMapInformation -) -> Tuple[int, int]: - if mapinfo.bss is not None: - version_from_bss = scan_core_bss_for_python_version(corefile, mapinfo.bss) - if version_from_bss is not None: - LOGGER.info( - "Version found by scanning the bss section: %d.%d", *version_from_bss - ) - return version_from_bss - return _get_python_version_from_map_information(mapinfo) +import gzip +import pathlib +import tempfile def is_elf(filename: pathlib.Path) -> bool: - "Return True if the given file is an ELF file" - elf_header = b"\x7fELF" - with open(filename, "br") as thefile: - return thefile.read(4) == elf_header - - -def get_thread_name(pid: int, tid: int) -> Optional[str]: + """Return True if the given file is an ELF file.""" try: - with open(f"/proc/{pid}/task/{tid}/comm") as comm: - return comm.read().strip() + elf_header = b"\x7fELF" + with open(filename, "br") as thefile: + return thefile.read(4) == elf_header except OSError: - return None + return False + + +def is_gzip(filename: pathlib.Path) -> bool: + """Check if the given file is a Gzip file based on the header. + + Args: + filename: The path to the file to be checked. + + Returns: + True if the file starts with the Gzip header, False otherwise. + """ + gzip_header = b"\x1f\x8b" + with open(filename, "rb") as thefile: + return thefile.read(2) == gzip_header + + +def decompress_gzip( + filename: pathlib.Path, chunk_size: int = 4 * 1024 * 1024 +) -> pathlib.Path: + """Decompress a Gzip file and write the contents to a temporary file. + + Args: + filename: The path to the gzip file to decompress. + chunk_size: Size of chunks to read and write at a time; defaults to 4MB. + + Returns: + The path to the temporary file containing the decompressed data. + + Raises: + gzip.BadGzipFile: If the file is not a valid gzip file. + """ + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + with gzip.open(filename, "rb") as file_handle: + while True: + chunk = file_handle.read(chunk_size) + if not chunk: + break + temp_file.write(chunk) + return pathlib.Path(temp_file.name) diff --git a/src/pystack/traceback_formatter.py b/src/pystack/traceback_formatter.py index 1df8f4c8..a87e45ad 100644 --- a/src/pystack/traceback_formatter.py +++ b/src/pystack/traceback_formatter.py @@ -1,9 +1,11 @@ import os import sys from typing import Iterable +from typing import List from typing import Optional from .colors import colored +from .engine import NativeReportingMode from .types import NativeFrame from .types import PyCodeObject from .types import PyFrame @@ -11,9 +13,21 @@ from .types import frame_type -def print_thread(thread: PyThread, native: bool) -> None: - for line in format_thread(thread, native): - print(line, file=sys.stdout, flush=True) +def print_threads(threads: List[PyThread], native_mode: NativeReportingMode) -> None: + multiple_interpreters = ( + len({t.interpreter_id for t in threads if t.interpreter_id is not None}) > 1 + ) + for i, thread in enumerate(threads): + same_tid_as_prev = i > 0 and thread.tid == threads[i - 1].tid + if i > 0 and not same_tid_as_prev: + print("", file=sys.stdout, flush=True) # Separate TIDs with a blank line + for line in format_thread( + thread, + native_mode, + show_thread_header=not same_tid_as_prev, + show_interpreter=multiple_interpreters, + ): + print(line, file=sys.stdout, flush=True) def format_frame(frame: PyFrame) -> Iterable[str]: @@ -26,18 +40,19 @@ def format_frame(frame: PyFrame) -> Iterable[str]: if os.path.exists(code.filename): with open(code.filename, "r") as fp: lines = fp.readlines() - source = lines[code.location.lineno - 1] - line_start, line_end, col_start, col_end = code.location - if col_start == col_end == 0: - yield f" {source.strip()}" - else: - if line_end != line_start: - col_end = len(source) - a = source[:col_start] - b = source[col_start:col_end] - c = source[col_end:] - final = f'{a}{colored(b, color="blue")}{c}' - yield f" {final.strip()}" + if 1 <= code.location.lineno <= len(lines): + source = lines[code.location.lineno - 1] + line_start, line_end, col_start, col_end = code.location + if col_start == col_end == 0: + yield f" {source.strip()}" + else: + if line_end != line_start: + col_end = len(source) + a = source[:col_start] + b = source[col_start:col_end].strip("\n") + c = source[col_end:] + final = f'{a}{colored(b, color="blue")}{c}' + yield f" {final.strip()}" if frame.arguments: yield f" {colored('Arguments:', attrs=['faint'])}" @@ -58,39 +73,62 @@ def _are_the_stacks_mergeable(thread: PyThread) -> bool: if frame_type(frame, thread.python_version) == NativeFrame.FrameType.EVAL ) n_eval_frames = sum(1 for _ in eval_frames) - n_entry_frames = sum(1 for frame in thread.frames if frame.is_entry) + n_entry_frames = sum(1 for frame in thread.all_frames if frame.is_entry) return n_eval_frames == n_entry_frames -def format_thread(thread: PyThread, native: bool) -> Iterable[str]: - current_frame: Optional[PyFrame] = thread.frame - if current_frame is None and not native: +def format_thread( + thread: PyThread, + native_mode: NativeReportingMode, + show_thread_header: bool = True, + show_interpreter: bool = False, +) -> Iterable[str]: + native = native_mode != NativeReportingMode.OFF + current_frame: Optional[PyFrame] = thread.first_frame + if current_frame is None and not native and not show_interpreter: yield f"The frame stack for thread {thread.tid} is empty" return thread_name = f" ({thread.name}) " if thread.name else " " - yield ( - f"Traceback for thread {thread.tid}{thread_name}{thread.status} " - "(most recent call last):" - ) + if show_thread_header: + yield ( + f"Traceback for thread {thread.tid}{thread_name}" + f"{thread.status + ' ' if not show_interpreter else ''}" + f"(most recent call last):" + ) + + if show_interpreter: + if thread.interpreter_id is None: + interp_name = "Not currently attached to any interpreter" + elif thread.interpreter_id == 0: + interp_name = "In the main interpreter" + else: + interp_name = f"In interpreter {thread.interpreter_id}" + yield f" {interp_name} {thread.status}" if not (native and _are_the_stacks_mergeable(thread)): if native: yield "* - Unable to merge native stack due to insufficient native information - *" while current_frame is not None: - yield from format_frame(current_frame) + if not current_frame.is_shim: + yield from format_frame(current_frame) current_frame = current_frame.next else: - yield from _format_merged_stacks(thread, current_frame) - yield "" + yield from _format_merged_stacks( + thread, current_frame, native_mode == NativeReportingMode.LAST + ) def _format_merged_stacks( - thread: PyThread, current_frame: Optional[PyFrame] + thread: PyThread, + current_frame: Optional[PyFrame], + native_last: bool = False, ) -> Iterable[str]: + c_frames_list: list[str] = [] for frame in thread.native_frames: if frame_type(frame, thread.python_version) == NativeFrame.FrameType.EVAL: assert current_frame is not None + c_frames_list = [] yield from format_frame(current_frame) current_frame = current_frame.next while current_frame and not current_frame.is_entry: @@ -101,13 +139,18 @@ def _format_merged_stacks( continue elif frame_type(frame, thread.python_version) == NativeFrame.FrameType.OTHER: function = colored(frame.symbol, "yellow") - yield ( + formatted_c_frame = ( f' {colored("(C)", "blue")} File "{frame.path}",' f" line {frame.linenumber}," f" in {function} ({colored(frame.library, attrs=['faint'])})" ) + if native_last: + c_frames_list.append(formatted_c_frame) + else: + yield formatted_c_frame else: # pragma: no cover raise ValueError( f"Invalid frame type: {frame_type(frame, thread.python_version)}" ) - return current_frame + for c_frame in c_frames_list: + yield c_frame diff --git a/src/pystack/types.py b/src/pystack/types.py index 870f8c09..2b0c27f1 100644 --- a/src/pystack/types.py +++ b/src/pystack/types.py @@ -7,6 +7,8 @@ from typing import Optional from typing import Tuple +from ._pystack import is_eval_frame as _is_eval_frame + SYMBOL_IGNORELIST = { "PyObject_Call", "call_function", @@ -44,12 +46,6 @@ class FrameType(enum.Enum): OTHER = 3 -def _is_eval_frame(symbol: str, python_version: Tuple[int, int]) -> bool: - if python_version < (3, 6): - return "PyEval_EvalFrameEx" in symbol - return "_PyEval_EvalFrameDefault" in symbol - - def frame_type( frame: NativeFrame, python_version: Optional[Tuple[int, int]] ) -> NativeFrame.FrameType: @@ -60,6 +56,8 @@ def frame_type( return frame.FrameType.IGNORE if symbol.startswith("_Py"): return frame.FrameType.IGNORE + if symbol.startswith("_TAIL_CALL_"): + return frame.FrameType.IGNORE if python_version and python_version >= (3, 8) and "vectorcall" in symbol.lower(): return frame.FrameType.IGNORE if any(symbol.startswith(ignored_symbol) for ignored_symbol in SYMBOL_IGNORELIST): @@ -97,6 +95,7 @@ class PyFrame: arguments: Dict[str, str] locals: Dict[str, str] is_entry: bool + is_shim: bool @dataclass @@ -108,9 +107,18 @@ class PyThread: is_gc_collecting: int python_version: Optional[Tuple[int, int]] name: Optional[str] = None + interpreter_id: Optional[int] = None @property def frames(self) -> Iterable[PyFrame]: + yield from filter(lambda frame: not frame.is_shim, self.all_frames) + + @property + def first_frame(self) -> Optional[PyFrame]: + return next(iter(self.frames), None) + + @property + def all_frames(self) -> Iterable[PyFrame]: current_frame = self.frame while current_frame is not None: yield current_frame diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..30edb5d7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +import os +import shutil +from pathlib import Path + +import pytest + + +def pytest_sessionstart(session): + if not shutil.which("gcore"): + pytest.exit("gcore not found (you probably forgot to install gdb)") + + # A restrictive Yama ptrace_scope stops pystack from attaching to a + # running process, so the integration tests hang instead of failing + # with a useful message. + ptrace_scope_path = "/proc/sys/kernel/yama/ptrace_scope" + try: + if os.getuid() == 0: + ptrace_scope = "0" + else: + ptrace_scope = Path(ptrace_scope_path).read_text().strip() + except OSError: + ptrace_scope = "0" + if ptrace_scope != "0": + pytest.exit( + f"ptrace_scope is {ptrace_scope}; pystack cannot attach to a running " + "process and the tests will hang. Run " + "'sudo sysctl kernel.yama.ptrace_scope=0' and try again." + ) 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/empty_thread_extension/main.py b/tests/integration/empty_thread_extension/main.py index b2d9f9f8..4ab88157 100644 --- a/tests/integration/empty_thread_extension/main.py +++ b/tests/integration/empty_thread_extension/main.py @@ -4,7 +4,6 @@ def foo(): - fifo = sys.argv[1] with open(sys.argv[1], "w") as fifo: fifo.write("ready") testext.sleep10() diff --git a/tests/integration/empty_thread_extension/testext.cpp b/tests/integration/empty_thread_extension/testext.cpp index a01868f7..faebd2b8 100644 --- a/tests/integration/empty_thread_extension/testext.cpp +++ b/tests/integration/empty_thread_extension/testext.cpp @@ -39,7 +39,11 @@ static struct PyModuleDef moduledef = {PyModuleDef_HEAD_INIT, "testext", "", -1, PyMODINIT_FUNC PyInit_testext(void) { - return PyModule_Create(&moduledef); + PyObject* mod = PyModule_Create(&moduledef); +# ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(mod, Py_MOD_GIL_NOT_USED); +# endif + return mod; } #else PyMODINIT_FUNC diff --git a/tests/integration/empty_thread_extension_with_os_threads/main.py b/tests/integration/empty_thread_extension_with_os_threads/main.py index b2d9f9f8..4ab88157 100644 --- a/tests/integration/empty_thread_extension_with_os_threads/main.py +++ b/tests/integration/empty_thread_extension_with_os_threads/main.py @@ -4,7 +4,6 @@ def foo(): - fifo = sys.argv[1] with open(sys.argv[1], "w") as fifo: fifo.write("ready") testext.sleep10() diff --git a/tests/integration/empty_thread_extension_with_os_threads/testext.cpp b/tests/integration/empty_thread_extension_with_os_threads/testext.cpp index a552646d..cc12fd32 100644 --- a/tests/integration/empty_thread_extension_with_os_threads/testext.cpp +++ b/tests/integration/empty_thread_extension_with_os_threads/testext.cpp @@ -69,7 +69,11 @@ static struct PyModuleDef moduledef = {PyModuleDef_HEAD_INIT, "testext", "", -1, PyMODINIT_FUNC PyInit_testext(void) { - return PyModule_Create(&moduledef); + PyObject* mod = PyModule_Create(&moduledef); +# ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(mod, Py_MOD_GIL_NOT_USED); +# endif + return mod; } #else PyMODINIT_FUNC diff --git a/tests/integration/gc_freeze_program.py b/tests/integration/gc_freeze_program.py index 9a8e1735..2ebaa7be 100644 --- a/tests/integration/gc_freeze_program.py +++ b/tests/integration/gc_freeze_program.py @@ -7,7 +7,6 @@ class A: def __del__(self): - fifo = sys.argv[1] with open(sys.argv[1], "w") as fifo: fifo.write("ready") time.sleep(1000) diff --git a/tests/integration/inline_calls_program.py b/tests/integration/inline_calls_program.py index cbcaa328..e4bf1660 100644 --- a/tests/integration/inline_calls_program.py +++ b/tests/integration/inline_calls_program.py @@ -20,7 +20,6 @@ def bar(): def baz(): - fifo = sys.argv[1] with open(sys.argv[1], "w") as fifo: fifo.write("ready") time.sleep(1000) diff --git a/tests/integration/multiple_thread_program.py b/tests/integration/multiple_thread_program.py index 4d3a1281..98f9343e 100644 --- a/tests/integration/multiple_thread_program.py +++ b/tests/integration/multiple_thread_program.py @@ -33,7 +33,6 @@ def third_func(): thread.start() while threads_active != 3: time.sleep(0.1) - fifo = sys.argv[1] with open(sys.argv[1], "w") as fifo: fifo.write("ready") time.sleep(1000) diff --git a/tests/integration/multiple_thread_program_gil.py b/tests/integration/multiple_thread_program_gil.py index 77293a2c..8886662d 100644 --- a/tests/integration/multiple_thread_program_gil.py +++ b/tests/integration/multiple_thread_program_gil.py @@ -34,7 +34,6 @@ def third_func(): thread.start() while threads_active != 3: time.sleep(0.1) - fifo = sys.argv[1] with open(sys.argv[1], "w") as fifo: fifo.write("ready") while True: diff --git a/tests/integration/shim_deallocation_program.py b/tests/integration/shim_deallocation_program.py new file mode 100644 index 00000000..b8905bcd --- /dev/null +++ b/tests/integration/shim_deallocation_program.py @@ -0,0 +1,14 @@ +import asyncio +import os + + +class AbortInDunderDel: + __del__ = os.abort + + +async def main(): + f = asyncio.Future() + f.set_result(AbortInDunderDel()) + + +asyncio.run(main()) diff --git a/tests/integration/single_thread_program.py b/tests/integration/single_thread_program.py index aa63d6f7..8e270a4c 100644 --- a/tests/integration/single_thread_program.py +++ b/tests/integration/single_thread_program.py @@ -11,7 +11,6 @@ def second_func(): def third_func(): - fifo = sys.argv[1] with open(sys.argv[1], "w") as fifo: fifo.write("ready") time.sleep(1000) diff --git a/tests/integration/single_thread_program_gil.py b/tests/integration/single_thread_program_gil.py index 62dc550d..5e53403e 100644 --- a/tests/integration/single_thread_program_gil.py +++ b/tests/integration/single_thread_program_gil.py @@ -11,7 +11,6 @@ def second_func(): def third_func(): collection = list(range(10000)) - fifo = sys.argv[1] with open(sys.argv[1], "w") as fifo: fifo.write("ready") while True: diff --git a/tests/integration/subinterpreters_nested_same_thread_program.py b/tests/integration/subinterpreters_nested_same_thread_program.py new file mode 100644 index 00000000..c9fb2ffb --- /dev/null +++ b/tests/integration/subinterpreters_nested_same_thread_program.py @@ -0,0 +1,31 @@ +import sys +import threading +import time + +from subinterpreters_shim import run_in_new_interpreter + +fifo = sys.argv[1] + +inner_code = f""" +import time + +with open({fifo!r}, "w") as f: + f.write("ready") + +while True: + time.sleep(1) +""" + +outer_code = f""" +import sys +sys.path.insert(0, {sys.path[0]!r}) + +from subinterpreters_shim import run_in_new_interpreter +run_in_new_interpreter({inner_code!r}) +""" + +t = threading.Thread(target=run_in_new_interpreter, args=(outer_code,)) +t.start() + +while True: + time.sleep(1) diff --git a/tests/integration/subinterpreters_program.py b/tests/integration/subinterpreters_program.py new file mode 100644 index 00000000..086fe9fe --- /dev/null +++ b/tests/integration/subinterpreters_program.py @@ -0,0 +1,42 @@ +import os +import sys +import threading +import time + +from subinterpreters_shim import run_in_new_interpreter + +NUM_INTERPRETERS = 3 + +r_fd, w_fd = os.pipe() + + +def start_interpreter_async(code): + t = threading.Thread(target=run_in_new_interpreter, args=(code,)) + t.start() + + +CODE = f""" +import os +import time + +os.write({w_fd}, b"x") + +while True: + time.sleep(1) +""" + +for _ in range(NUM_INTERPRETERS): + start_interpreter_async(CODE) + +data = b"" +while len(data) < NUM_INTERPRETERS: + data += os.read(r_fd, NUM_INTERPRETERS - len(data)) +os.close(r_fd) +os.close(w_fd) + +fifo = sys.argv[1] +with open(fifo, "w") as f: + f.write("ready") + +while True: + time.sleep(1) diff --git a/tests/integration/subinterpreters_shim.py b/tests/integration/subinterpreters_shim.py new file mode 100644 index 00000000..3a009098 --- /dev/null +++ b/tests/integration/subinterpreters_shim.py @@ -0,0 +1,20 @@ +try: + from concurrent import interpreters # type: ignore + + def run_in_new_interpreter(code): + interpreters.create().exec(code) + +except ImportError: + try: + import _interpreters # type: ignore + + def run_in_new_interpreter(code): + _interpreters.exec(_interpreters.create(), code) + + except ImportError: + import _xxsubinterpreters # type: ignore + + def run_in_new_interpreter(code): + _xxsubinterpreters.run_string( + _xxsubinterpreters.create(isolated=False), code + ) diff --git a/tests/integration/subinterpreters_two_chains_program.py b/tests/integration/subinterpreters_two_chains_program.py new file mode 100644 index 00000000..ca984b69 --- /dev/null +++ b/tests/integration/subinterpreters_two_chains_program.py @@ -0,0 +1,42 @@ +import os +import sys +import threading +import time + +from subinterpreters_shim import run_in_new_interpreter + +r_fd, w_fd = os.pipe() + + +def launch_chain(): + level3_code = f"import os, time; os.write({w_fd}, b'x'); time.sleep(86400)" + level2_code = ( + f"import sys; sys.path.insert(0, {sys.path[0]!r}); " + f"from subinterpreters_shim import run_in_new_interpreter as f;" + f"f({level3_code!r})" + ) + level1_code = ( + f"import sys; sys.path.insert(0, {sys.path[0]!r}); " + f"from subinterpreters_shim import run_in_new_interpreter as f;" + f"f({level2_code!r})" + ) + run_in_new_interpreter(level1_code) + + +t1 = threading.Thread(target=launch_chain, daemon=True) +t2 = threading.Thread(target=launch_chain, daemon=True) +t1.start() +t2.start() + +data = b"" +while len(data) < 2: + data += os.read(r_fd, 2 - len(data)) +os.close(r_fd) +os.close(w_fd) + +fifo = sys.argv[1] +with open(fifo, "w") as f: + f.write("ready") + +while True: + time.sleep(1) diff --git a/tests/integration/subinterpreters_with_threads_program.py b/tests/integration/subinterpreters_with_threads_program.py new file mode 100644 index 00000000..22991993 --- /dev/null +++ b/tests/integration/subinterpreters_with_threads_program.py @@ -0,0 +1,62 @@ +import os +import sys +import threading +import time + +from subinterpreters_shim import run_in_new_interpreter + +NUM_INTERPRETERS = 2 +NUM_THREADS_PER_SUBINTERPRETER = 2 + +r_fd, w_fd = os.pipe() + + +def start_interpreter_async(code): + t = threading.Thread(target=run_in_new_interpreter, args=(code,)) + t.daemon = True + t.start() + return t + + +CODE = """\ +import os +import threading +import time + +NUM_THREADS = 2 + +def worker(): + os.write(%d, b"x") + while True: + time.sleep(1) + +threads = [] +for _ in range(NUM_THREADS): + t = threading.Thread(target=worker) + t.start() + threads.append(t) + +os.write(%d, b"x") +while True: + time.sleep(1) +""" % (w_fd, w_fd) + +threads = [] +for _ in range(NUM_INTERPRETERS): + t = start_interpreter_async(CODE) + threads.append(t) + +TOTAL_EXPECTED = NUM_INTERPRETERS * (NUM_THREADS_PER_SUBINTERPRETER + 1) + +data = b"" +while len(data) < TOTAL_EXPECTED: + data += os.read(r_fd, TOTAL_EXPECTED - len(data)) +os.close(r_fd) +os.close(w_fd) + +fifo = sys.argv[1] +with open(fifo, "w") as f: + f.write("ready") + +while True: + time.sleep(1) diff --git a/tests/integration/test_core_analyzer.py b/tests/integration/test_core_analyzer.py index 538cadba..3adb4a80 100644 --- a/tests/integration/test_core_analyzer.py +++ b/tests/integration/test_core_analyzer.py @@ -1,10 +1,11 @@ +import logging import os import re import shutil +import struct import subprocess import sys from pathlib import Path -from unittest.mock import patch import pytest @@ -14,8 +15,6 @@ from pystack.engine import get_process_threads_for_core from pystack.errors import EngineError from pystack.errors import NotEnoughInformation -from pystack.maps import MemoryMapInformation -from pystack.maps import VirtualMap from pystack.types import LocationInfo from pystack.types import NativeFrame from pystack.types import frame_type @@ -24,6 +23,8 @@ from tests.utils import ALL_PYTHONS_THAT_SUPPORT_ELF_DATA from tests.utils import PythonVersion from tests.utils import all_pystack_combinations +from tests.utils import all_pythons_since +from tests.utils import generate_core_after_crash from tests.utils import generate_core_file from tests.utils import python_has_inlined_eval_frames from tests.utils import python_has_position_information @@ -41,6 +42,7 @@ TEST_NO_FRAMES_AT_SHUTDOWN_FILE = ( Path(__file__).parent / "no_frames_at_shutdown_program.py" ) +TEST_SHIM_DEALLOCATION_FILE = Path(__file__).parent / "shim_deallocation_program.py" @all_pystack_combinations(corefile=True) @@ -82,7 +84,7 @@ def test_single_thread_stack( assert functions == ["", "first_func", "second_func", "third_func"] *line_numbers, last_line = [frame.code.location.lineno for frame in frames] - assert line_numbers == [20, 6, 10] + assert line_numbers == [19, 6, 10] assert last_line in {16, 17} assert thread.native_frames @@ -131,7 +133,7 @@ def test_single_thread_stack_from_elf_data(python: PythonVersion, tmpdir: Path) assert functions == ["", "first_func", "second_func", "third_func"] *line_numbers, last_line = [frame.code.location.lineno for frame in frames] - assert line_numbers == [20, 6, 10] + assert line_numbers == [19, 6, 10] assert last_line in {16, 17} assert thread.native_frames @@ -197,7 +199,7 @@ def test_multiple_thread_stack_native( main_thread = [ thread for thread in threads - if thread.frame and "threading" not in thread.frame.code.filename + if thread.first_frame and "threading" not in thread.first_frame.code.filename ][0] other_threads = [thread for thread in threads if thread != main_thread] @@ -211,7 +213,7 @@ def test_multiple_thread_stack_native( assert functions == ["", "first_func", "second_func", "third_func"] *line_numbers, last_line = [frame.code.location.lineno for frame in frames] - assert line_numbers == [42, 23, 27] + assert line_numbers == [41, 23, 27] assert last_line in {38, 39} assert main_thread.native_frames @@ -253,98 +255,6 @@ def test_multiple_thread_stack_native( ) -@ALL_PYTHONS -def test_gather_stack_with_heap_fails_if_no_heap(python: PythonVersion, tmpdir: Path): - # GIVEN - _, python_executable = python - - def heap_dropping_memory_map_info(*args, **kwargs): - ret = MemoryMapInformation(*args, **kwargs) - ret.heap = None - return ret - - err = "Could not gather enough information to extract the Python frame information" - - # WHEN - with generate_core_file( - python_executable, TEST_MULTIPLE_THREADS_FILE, tmpdir - ) as core_file: - with patch("pystack.maps.MemoryMapInformation", heap_dropping_memory_map_info): - # THEN - - with pytest.raises(NotEnoughInformation, match=err): - list( - get_process_threads_for_core( - core_file, python_executable, method=StackMethod.HEAP - ) - ) - - -@ALL_PYTHONS -def test_gather_stack_with_bss_fails_if_no_bss(python: PythonVersion, tmpdir: Path): - # GIVEN - _, python_executable = python - - def bss_dropping_memory_map_info(*args, **kwargs): - ret = MemoryMapInformation(*args, **kwargs) - ret.bss = None - return ret - - err = "Could not gather enough information to extract the Python frame information" - - # WHEN - with generate_core_file( - python_executable, TEST_MULTIPLE_THREADS_FILE, tmpdir - ) as core_file: - with patch("pystack.maps.MemoryMapInformation", bss_dropping_memory_map_info): - # THEN - - with pytest.raises(NotEnoughInformation, match=err): - list( - get_process_threads_for_core( - core_file, python_executable, method=StackMethod.BSS - ) - ) - - -@ALL_PYTHONS -def test_gather_stack_with_heap_fails_if_heap_address_is_wrong( - python: PythonVersion, tmpdir: Path -): - # GIVEN - _, python_executable = python - - def heap_dropping_memory_map_info(*args, **kwargs): - ret = MemoryMapInformation(*args, **kwargs) - ret.heap = VirtualMap( - start=1024, - end=2048, - filesize=1024, - offset=2**31, - device="", - flags="rw", - inode=0, - path=Path("[heap]"), - ) - return ret - - err = "Could not gather enough information to extract the Python frame information" - - # WHEN - with generate_core_file( - python_executable, TEST_MULTIPLE_THREADS_FILE, tmpdir - ) as core_file: - with patch("pystack.maps.MemoryMapInformation", heap_dropping_memory_map_info): - # THEN - - with pytest.raises(NotEnoughInformation, match=err): - list( - get_process_threads_for_core( - core_file, python_executable, method=StackMethod.HEAP - ) - ) - - def test_thread_registered_with_python_with_other_threads(tmpdir): # WHEN extension_name = "empty_thread_extension_with_os_threads" @@ -367,12 +277,18 @@ def test_thread_registered_with_python_with_other_threads(tmpdir): # THEN assert len(threads) == 3 - (main_thread, second_thread, non_python_thread) = threads + main_thread, second_thread, non_python_thread = threads main_frames = list(main_thread.frames) assert not main_frames assert main_thread.native_frames - assert any(["sleepThread" in frame.symbol for frame in main_thread.native_frames]) + # On some platforms (e.g. glibc 2.42+), native unwinding through + # __syscall_cancel_arch may be truncated in core files, preventing + # us from seeing the full native stack including sleepThread. + if len(main_thread.native_frames) > 1: + assert any( + ["sleepThread" in frame.symbol for frame in main_thread.native_frames] + ) frames = list(second_thread.frames) assert (len(frames)) == 2 @@ -384,17 +300,20 @@ def test_thread_registered_with_python_with_other_threads(tmpdir): assert functions == ["", "foo"] lines = [frame.code.location.lineno for frame in frames] - assert lines == [13, 10] + assert lines == [12, 9] native_frames = list(non_python_thread.native_frames) - assert len(native_frames) >= 4 - symbols = {frame.symbol for frame in native_frames} - assert any( - [ - expected_symbol in symbols - for expected_symbol in {"sleep", "__nanosleep", "nanosleep"} - ] - ) + assert len(native_frames) >= 1 + # On some platforms (e.g. glibc 2.42+), native unwinding through + # syscall wrappers may be truncated in core files. + if len(native_frames) >= 4: + symbols = {frame.symbol for frame in native_frames} + assert any( + [ + expected_symbol in symbols + for expected_symbol in {"sleep", "__nanosleep", "nanosleep"} + ] + ) @ALL_PYTHONS @@ -644,6 +563,44 @@ def test_core_analizer_raises_when_an_invalid_core_is_provided(tmpdir: Path) -> list(get_process_threads_for_core(Path(not_a_core), Path(sys.executable))) +@pytest.mark.parametrize( + ("ident_index", "incompatible_value"), + [ + (5, 2 if sys.byteorder == "little" else 1), # EI_DATA + (4, 1 if sys.maxsize > 2**32 else 2), # EI_CLASS + ], +) +def test_core_analyzer_rejects_incompatible_core_format( + tmpdir: Path, ident_index: int, incompatible_value: int +) -> None: + core = bytearray((CORE_FILE_PATHS / "segfault.core").read_bytes()) + core[ident_index] = incompatible_value + incompatible_core = Path(tmpdir) / "incompatible.core" + incompatible_core.write_bytes(core) + + with pytest.raises(RuntimeError, match="unsupported format"): + CoreFileAnalyzer(str(incompatible_core)) + + +def test_core_analyzer_rejects_malformed_nt_file( + tmpdir: Path, caplog: pytest.LogCaptureFixture +) -> None: + core = bytearray((CORE_FILE_PATHS / "segfault.core").read_bytes()) + ulong_size = 8 + desc, descsz = (2148, 451) + struct.pack_into( + " None: for filename, core_id, elf_id in build_ids: assert filename is not None and filename != "" assert core_id == elf_id + + +@all_pythons_since(3, 11) +def test_shim_frame_before_new_python_function_is_scheduled( + python: PythonVersion, tmpdir: Path +) -> None: + # GIVEN + + (major_version, minor_version), python_executable = python + + # WHEN + + with generate_core_after_crash( + python_executable, TEST_SHIM_DEALLOCATION_FILE, tmpdir + ) as core_file: + threads = list(get_process_threads_for_core(core_file, python_executable)) + + # THEN + assert len(threads) == 1 + (thread,) = threads + + frames = list(thread.frames) + assert (len(frames)) == 7 + + filenames = {frame.code.filename for frame in frames} + assert str(TEST_SHIM_DEALLOCATION_FILE) in filenames + + functions = [frame.code.scope for frame in frames] + assert functions == [ + "", + "run", + "run", + "run_until_complete", + "run_forever", + "_run_once", + "_run", + ] + + assert thread.native_frames + eval_frames = [ + frame + for frame in thread.native_frames + if frame_type(frame, thread.python_version) == NativeFrame.FrameType.EVAL + ] + assert len(eval_frames) >= 1 + assert all("?" not in frame.symbol for frame in eval_frames) + assert all(frame.linenumber != 0 for frame in eval_frames if "?" not in frame.path) diff --git a/tests/integration/test_gather_stacks.py b/tests/integration/test_gather_stacks.py index 813cfdd7..5d2de3a6 100644 --- a/tests/integration/test_gather_stacks.py +++ b/tests/integration/test_gather_stacks.py @@ -2,22 +2,19 @@ import subprocess import sys from pathlib import Path -from unittest.mock import mock_open -from unittest.mock import patch import pytest from pystack.engine import NativeReportingMode -from pystack.engine import StackMethod from pystack.engine import get_process_threads -from pystack.errors import NotEnoughInformation -from pystack.maps import MAPS_REGEXP -from pystack.process import get_thread_name +from pystack.engine import get_process_threads_for_core from pystack.types import LocationInfo from pystack.types import NativeFrame from pystack.types import frame_type from tests.utils import ALL_PYTHONS +from tests.utils import AVAILABLE_PYTHONS from tests.utils import all_pystack_combinations +from tests.utils import generate_core_file from tests.utils import python_has_inlined_eval_frames from tests.utils import python_has_position_information from tests.utils import spawn_child_process @@ -34,6 +31,7 @@ TEST_NO_FRAMES_AT_SHUTDOWN_FILE = ( Path(__file__).parent / "no_frames_at_shutdown_program.py" ) +TEST_SIGNAL_HANDLER_EXTENSION = Path(__file__).parent / "testext" @all_pystack_combinations() @@ -69,7 +67,7 @@ def test_single_thread_stack(python, blocking, method, tmpdir): assert functions == ["", "first_func", "second_func", "third_func"] *line_numbers, last_line = [frame.code.location.lineno for frame in frames] - assert line_numbers == [20, 6, 10] + assert line_numbers == [19, 6, 10] assert last_line in {16, 17} assert not thread.native_frames @@ -98,7 +96,7 @@ def test_single_thread_stack_non_blocking(tmpdir): assert functions == ["", "first_func", "second_func", "third_func"] *line_numbers, last_line = [frame.code.location.lineno for frame in frames] - assert line_numbers == [20, 6, 10] + assert line_numbers == [19, 6, 10] assert last_line in {16, 17} assert not thread.native_frames @@ -126,7 +124,9 @@ def test_multiple_thread_stack(python, blocking, method, tmpdir): assert len(threads) == 4 main_thread = [ - thread for thread in threads if "threading" not in thread.frame.code.filename + thread + for thread in threads + if "threading" not in thread.first_frame.code.filename ][0] other_threads = [thread for thread in threads if thread != main_thread] @@ -140,7 +140,7 @@ def test_multiple_thread_stack(python, blocking, method, tmpdir): assert functions == ["", "first_func", "second_func", "third_func"] *line_numbers, last_line = [frame.code.location.lineno for frame in frames] - assert line_numbers == [42, 23, 27] + assert line_numbers == [41, 23, 27] assert last_line in {38, 39} assert not main_thread.native_frames @@ -199,7 +199,7 @@ def test_single_thread_stack_native(python, method, blocking, tmpdir): assert functions == ["", "first_func", "second_func", "third_func"] *line_numbers, last_line = [frame.code.location.lineno for frame in frames] - assert line_numbers == [20, 6, 10] + assert line_numbers == [19, 6, 10] assert last_line in {16, 17} assert thread.native_frames @@ -245,7 +245,9 @@ def test_multiple_thread_stack_native(python, method, blocking, tmpdir): assert len(threads) == 4 main_thread = [ - thread for thread in threads if "threading" not in thread.frame.code.filename + thread + for thread in threads + if "threading" not in thread.first_frame.code.filename ][0] other_threads = [thread for thread in threads if thread != main_thread] @@ -259,7 +261,7 @@ def test_multiple_thread_stack_native(python, method, blocking, tmpdir): assert functions == ["", "first_func", "second_func", "third_func"] *line_numbers, last_line = [frame.code.location.lineno for frame in frames] - assert line_numbers == [42, 23, 27] + assert line_numbers == [41, 23, 27] assert last_line in {38, 39} assert main_thread.native_frames @@ -309,148 +311,108 @@ def test_multiple_thread_stack_native(python, method, blocking, tmpdir): assert any(frame.path and "?" not in frame.path for frame in eval_frames) -def test_gather_stack_with_heap_fails_if_no_heap(tmpdir): - # GIVEN / WHEN +def build_signal_handler_extension(python_executable, tmpdir): + """Build the testext extension in tmpdir and return its driver script.""" + extension_path = tmpdir / "testext" + shutil.copytree(TEST_SIGNAL_HANDLER_EXTENSION, extension_path) + subprocess.run( + [python_executable, str(extension_path / "setup.py"), "build_ext", "--inplace"], + check=True, + cwd=extension_path, + capture_output=True, + ) + return extension_path / "main.py" - with spawn_child_process( - sys.executable, TEST_SINGLE_THREAD_FILE, tmpdir - ) as child_process: - the_data = [] - with open(f"/proc/{child_process.pid}/maps") as f: - for line in f.readlines(): - match = MAPS_REGEXP.match(line) - assert match is not None - if match.group("pathname") and "[heap]" in match.group("pathname"): - line = line.replace("[heap]", "[mysterious_segment]") - the_data.append(line) - data = "".join(the_data) - with patch("builtins.open", mock_open(read_data=data)): - # THEN - - with pytest.raises(NotEnoughInformation): - list( - get_process_threads( - child_process.pid, stop_process=True, method=StackMethod.HEAP - ) - ) +def assert_stack_was_unwound_through_signal_frame(thread, program): + """Ensure we got the expected Python stack and the right number of eval frames.""" + frames = list(thread.frames) -def test_gather_stack_with_bss_fails_if_no_bss(tmpdir): - # GIVEN / WHEN + filenames = {frame.code.filename for frame in frames} + assert filenames == {str(program)} - with spawn_child_process( - sys.executable, TEST_SINGLE_THREAD_FILE, tmpdir - ) as child_process: - the_data = [] - with open(f"/proc/{child_process.pid}/maps") as f: - for line in f.readlines(): - match = MAPS_REGEXP.match(line) - assert match is not None - if not match.group("pathname"): - line = line.replace("\n", "[mysterious_segment]\n") - the_data.append(line) - - data = "".join(the_data) - - with patch("builtins.open", mock_open(read_data=data)), patch( - "pystack.maps._get_bss", return_value=None - ): - # THEN - - with pytest.raises(NotEnoughInformation): - list( - get_process_threads( - child_process.pid, stop_process=True, method=StackMethod.BSS - ) - ) + functions = [frame.code.scope for frame in frames] + assert functions == ["", "first_func", "second_func", "third_func"] + native_frames = list(thread.native_frames) + assert native_frames -def test_gather_stack_auto_works_if_no_bss(tmpdir): - # GIVEN / WHEN + eval_frames = [ + frame + for frame in native_frames + if frame_type(frame, thread.python_version) == NativeFrame.FrameType.EVAL + ] + assert eval_frames + assert len(eval_frames) == sum(frame.is_entry for frame in thread.frames) + assert all("?" not in frame.symbol for frame in eval_frames) - with spawn_child_process( - sys.executable, TEST_SINGLE_THREAD_FILE, tmpdir - ) as child_process: - the_data = [] - with open(f"/proc/{child_process.pid}/maps") as f: - for line in f.readlines(): - match = MAPS_REGEXP.match(line) - assert match is not None - if not match.group("pathname"): - line = line.replace("\n", "[mysterious_segment]\n") - the_data.append(line) - data = "".join(the_data) - with patch("builtins.open", mock_open(read_data=data)), patch( - "pystack.maps._get_bss", return_value=None - ): - threads = list( - get_process_threads( - child_process.pid, stop_process=True, method=StackMethod.AUTO + +ALL_PYTHONS_BUT_XFAIL_ON_ALPINE = pytest.mark.parametrize( + "python", + [ + pytest.param( + python[:2], + marks=( + pytest.mark.xfail( + python.uses_musl, + reason="unwinding through signal frames fails on x86-64 Alpine", ) + ), + ) + for python in AVAILABLE_PYTHONS + ], + ids=[python[1].name for python in AVAILABLE_PYTHONS], +) + + +@ALL_PYTHONS_BUT_XFAIL_ON_ALPINE +def test_stack_of_thread_in_signal_handler(python, tmpdir): + # GIVEN + + _, python_executable = python + + # WHEN + + program = build_signal_handler_extension(python_executable, tmpdir) + with spawn_child_process(python_executable, program, tmpdir) as child_process: + threads = list( + get_process_threads( + child_process.pid, + stop_process=True, + native_mode=NativeReportingMode.PYTHON, ) + ) # THEN assert len(threads) == 1 (thread,) = threads + assert_stack_was_unwound_through_signal_frame(thread, program) - frames = list(thread.frames) - assert (len(frames)) == 4 - - filenames = {frame.code.filename for frame in frames} - assert filenames == {str(TEST_SINGLE_THREAD_FILE)} - - functions = [frame.code.scope for frame in frames] - assert functions == ["", "first_func", "second_func", "third_func"] - - *line_numbers, last_line = [frame.code.location.lineno for frame in frames] - assert line_numbers == [20, 6, 10] - assert last_line in {16, 17} - assert not thread.native_frames +@ALL_PYTHONS_BUT_XFAIL_ON_ALPINE +def test_stack_of_thread_in_signal_handler_for_core(python, tmpdir): + # GIVEN + _, python_executable = python -def test_gather_stack_auto_works_if_no_heap(tmpdir): - # GIVEN / WHEN + # WHEN - with spawn_child_process( - sys.executable, TEST_SINGLE_THREAD_FILE, tmpdir - ) as child_process: - the_data = [] - with open(f"/proc/{child_process.pid}/maps") as f: - for line in f.readlines(): - match = MAPS_REGEXP.match(line) - assert match is not None - if match.group("pathname") and "[heap]" in match.group("pathname"): - line = line.replace("[heap]", "[mysterious_segment]") - the_data.append(line) - data = "".join(the_data) - with patch("builtins.open", mock_open(read_data=data)): - threads = list( - get_process_threads( - child_process.pid, stop_process=True, method=StackMethod.AUTO - ) + program = build_signal_handler_extension(python_executable, tmpdir) + with generate_core_file(python_executable, program, tmpdir) as core_file: + threads = list( + get_process_threads_for_core( + core_file, + Path(python_executable), + native_mode=NativeReportingMode.PYTHON, ) + ) # THEN assert len(threads) == 1 (thread,) = threads - - frames = list(thread.frames) - assert (len(frames)) == 4 - - filenames = {frame.code.filename for frame in frames} - assert filenames == {str(TEST_SINGLE_THREAD_FILE)} - - functions = [frame.code.scope for frame in frames] - assert functions == ["", "first_func", "second_func", "third_func"] - - *line_numbers, last_line = [frame.code.location.lineno for frame in frames] - assert line_numbers == [20, 6, 10] - assert last_line in {16, 17} - - assert not thread.native_frames + assert_stack_was_unwound_through_signal_frame(thread, program) @ALL_PYTHONS @@ -483,7 +445,7 @@ def test_thread_registered_with_python_but_with_no_python_calls(python, tmpdir): # THEN assert len(threads) == 2 - (main_thread, second_thread) = threads + main_thread, second_thread = threads main_frames = list(main_thread.frames) assert not main_frames @@ -500,7 +462,7 @@ def test_thread_registered_with_python_but_with_no_python_calls(python, tmpdir): assert functions == ["", "foo"] lines = [frame.code.location.lineno for frame in frames] - assert lines == [13, 10] + assert lines == [12, 9] def test_thread_registered_with_python_with_other_threads(tmpdir): @@ -525,7 +487,7 @@ def test_thread_registered_with_python_with_other_threads(tmpdir): # THEN assert len(threads) == 3 - (main_thread, second_thread, non_python_thread) = threads + main_thread, second_thread, non_python_thread = threads main_frames = list(main_thread.frames) assert not main_frames @@ -542,7 +504,7 @@ def test_thread_registered_with_python_with_other_threads(tmpdir): assert functions == ["", "foo"] lines = [frame.code.location.lineno for frame in frames] - assert lines == [13, 10] + assert lines == [12, 9] native_frames = list(non_python_thread.native_frames) assert len(native_frames) >= 4 @@ -580,14 +542,6 @@ def test_get_thread_name(tmpdir): assert "thread_foo" in {thread.name for thread in threads} -def test_get_thread_name_oserror(): - # WHEN - thread_name = get_thread_name(pid=0, tid=0) - - # THEN - assert thread_name is None - - @ALL_PYTHONS def test_inlined_python_calls(python, tmpdir): # GIVEN diff --git a/tests/integration/test_gil.py b/tests/integration/test_gil.py index 0dc15e56..a29d7fa5 100644 --- a/tests/integration/test_gil.py +++ b/tests/integration/test_gil.py @@ -1,5 +1,8 @@ +import subprocess from pathlib import Path +import pytest + from pystack.engine import get_process_threads from pystack.engine import get_process_threads_for_core from tests.utils import ALL_PYTHONS @@ -14,6 +17,15 @@ TEST_SINGLE_THREAD_FILE = Path(__file__).parent / "single_thread_program.py" +@pytest.fixture(autouse=True) +def enable_gil_if_free_threading(python, monkeypatch): + _, python_executable = python + proc = subprocess.run([python_executable, "-Xgil=1", "-cpass"], capture_output=True) + free_threading = proc.returncode == 0 + if free_threading: + monkeypatch.setenv("PYTHON_GIL", "1") + + @ALL_PYTHONS def test_gil_status_one_thread_among_many_holds_the_gil(python, tmpdir): # GIVEN diff --git a/tests/integration/test_local_variables.py b/tests/integration/test_local_variables.py index ed528ccb..2156a78b 100644 --- a/tests/integration/test_local_variables.py +++ b/tests/integration/test_local_variables.py @@ -558,3 +558,83 @@ def second_func(the_argument): second_func_frame = find_frame(frames, "second_func") assert "the_argument" not in second_func_frame.arguments + + +@ALL_PYTHONS +@ALL_SOURCES +def test_trashed_locals(generate_threads, python, tmpdir): + # GIVEN + _, python_executable = python + + program_template = """ +import ctypes +import sys +import time + +class ListObject(ctypes.Structure): + _fields_ = [ + ("ob_type", ctypes.c_void_p), + ("ob_size", ctypes.c_ssize_t), + ("ob_item", ctypes.c_void_p), + ] + +if sys.version_info >= (3, 14): + class TupleObject(ctypes.Structure): + _fields_ = [ + ("ob_type", ctypes.c_void_p), + ("ob_size", ctypes.c_ssize_t), + ("ob_hash", ctypes.c_ssize_t), + ("ob_item0", ctypes.c_void_p), + ("ob_item1", ctypes.c_void_p), + ] +else: + class TupleObject(ctypes.Structure): + _fields_ = [ + ("ob_type", ctypes.c_void_p), + ("ob_size", ctypes.c_ssize_t), + ("ob_item0", ctypes.c_void_p), + ("ob_item1", ctypes.c_void_p), + ] + +def ob_type_field(obj): + # Assume ob_type is the last field of PyObject + return id(obj) + sys.getsizeof(None) - ctypes.sizeof(ctypes.c_void_p) + +def main(): + bad_type = (1, 2, 3) + bad_elem = (4, 5, 6) + nullelem = (7, 8, 9) + bad_list = [0, 1, 2] + + TupleObject.from_address(ob_type_field(bad_type)).ob_type = 0xded + TupleObject.from_address(ob_type_field(bad_elem)).ob_item1 = 0xbad + TupleObject.from_address(ob_type_field(nullelem)).ob_item1 = 0x0 + ListObject.from_address(ob_type_field(bad_list)).ob_item = 0x0 + + fifo = sys.argv[1] + with open(sys.argv[1], "w") as fifo: + fifo.write("ready") + time.sleep(1000) + +main() + """ + + script = tmpdir / "the_script.py" + script.write_text(program_template, encoding="utf-8") + + # WHEN + threads = generate_threads(python_executable, script, tmpdir, locals=True) + + # THEN + + assert len(threads) == 1 + (thread,) = threads + + frames = list(thread.frames) + assert (len(frames)) == 2 + + main = find_frame(frames, "main") + assert re.match(r"^$", main.locals["bad_type"]) + assert main.locals["bad_elem"] == "(4, , 6)" + assert main.locals["nullelem"] == "(7, , 9)" + assert re.match(r"^$", main.locals["bad_list"]) diff --git a/tests/integration/test_process.py b/tests/integration/test_process.py index b8897f0b..190c4443 100644 --- a/tests/integration/test_process.py +++ b/tests/integration/test_process.py @@ -1,72 +1,20 @@ import sys +import threading +from concurrent.futures import ThreadPoolExecutor from pathlib import Path import pytest from pystack._pystack import ProcessManager -from pystack.engine import CoreFileAnalyzer from pystack.engine import get_process_threads -from pystack.errors import EngineError -from pystack.maps import generate_maps_for_process -from pystack.maps import parse_maps_file -from pystack.maps import parse_maps_file_for_binary from pystack.process import is_elf -from pystack.process import scan_core_bss_for_python_version -from pystack.process import scan_process_bss_for_python_version from tests.utils import ALL_PYTHONS -from tests.utils import generate_core_file from tests.utils import spawn_child_process TEST_SINGLE_THREAD_FILE = Path(__file__).parent / "single_thread_program.py" TEST_SHUTDOWN_FILE = Path(__file__).parent / "shutdown_program.py" -@ALL_PYTHONS -def test_remote_version_detection_using_bss_section(python, tmpdir): - # GIVEN - - (expected_major, expected_minor), python_executable = python - - # WHEN - - with spawn_child_process( - python_executable, TEST_SINGLE_THREAD_FILE, tmpdir - ) as child_process: - all_maps = generate_maps_for_process(child_process.pid) - maps = parse_maps_file(child_process.pid, all_maps) - major, minor = scan_process_bss_for_python_version(child_process.pid, maps.bss) - - # THEN - - assert major == expected_major - assert minor == expected_minor - - -@ALL_PYTHONS -def test_core_version_detection_using_bss_section(python, tmpdir): - # GIVEN - - (expected_major, expected_minor), python_executable = python - - # WHEN - - with generate_core_file( - python_executable, TEST_SINGLE_THREAD_FILE, tmpdir - ) as corefile: - core_map_analyzer = CoreFileAnalyzer(str(corefile), str(python_executable)) - virtual_maps = tuple(core_map_analyzer.extract_maps()) - load_point_by_module = core_map_analyzer.extract_module_load_points() - maps = parse_maps_file_for_binary( - python_executable, virtual_maps, load_point_by_module - ) - major, minor = scan_core_bss_for_python_version(corefile, maps.bss) - - # THEN - - assert major == expected_major - assert minor == expected_minor - - @ALL_PYTHONS def test_detection_of_interpreter_shutdown(python, tmpdir): # GIVEN @@ -120,11 +68,29 @@ def test_reattaching_to_already_traced_process(python, tmpdir): pid = child_process.pid # WHEN / THEN - with pytest.raises(EngineError, match="Operation not permitted"): - it1 = iter(get_process_threads(pid, stop_process=True)) - it2 = iter(get_process_threads(pid, stop_process=True)) - next(it1) - next(it2) + # Use threading to create overlapping attachment attempts. + # The first thread holds the ptrace attachment while the second tries to attach. + barrier = threading.Barrier(2) + results = [] + errors = [] + + def attach_thread(): + try: + barrier.wait(timeout=5) # Synchronize start + threads = list(get_process_threads(pid, stop_process=True)) + results.append(len(threads)) + except Exception as e: + errors.append(str(e)) + + with ThreadPoolExecutor(max_workers=2) as executor: + f1 = executor.submit(attach_thread) + f2 = executor.submit(attach_thread) + f1.result(timeout=10) + f2.result(timeout=10) + + # One should succeed, one should fail with "Operation not permitted" + assert len(results) + len(errors) == 2 + assert any("Operation not permitted" in err for err in errors) @pytest.mark.parametrize( @@ -132,6 +98,7 @@ def test_reattaching_to_already_traced_process(python, tmpdir): [ (sys.executable, True), (__file__, False), + ("/etc", False), ], ) def test_elf_checker(file, expected): diff --git a/tests/integration/test_relocatable_cores.py b/tests/integration/test_relocatable_cores.py index b78515d6..c29b8b05 100644 --- a/tests/integration/test_relocatable_cores.py +++ b/tests/integration/test_relocatable_cores.py @@ -3,6 +3,7 @@ import sys from pathlib import Path +import pytest from pytest import LogCaptureFixture from pystack.engine import CoreFileAnalyzer @@ -17,6 +18,10 @@ TEST_MULTIPLE_THREADS_FILE = Path(__file__).parent / "multiple_thread_program.py" +@pytest.mark.skipif( + sys.version_info >= (3, 15), + reason="PyInstaller does not support Python 3.15 yet", +) def test_single_thread_stack_for_relocated_core( tmpdir: Path, caplog: LogCaptureFixture ) -> None: @@ -25,7 +30,6 @@ def test_single_thread_stack_for_relocated_core( that we can inspect and obtain all the information we need from the frame stack, including the native frames by using symbols only. """ - # GIVEN target_bundle = Path(tmpdir / "bundle") @@ -47,14 +51,21 @@ def test_single_thread_stack_for_relocated_core( caplog.set_level(logging.WARNING) # WHEN - with generate_core_file(Path("env"), executable, tmpdir) as core_file: + with generate_core_file( + Path("env"), executable, tmpdir, disable_site=False + ) as core_file: target_bundle.rename(relocated_bundle) # Hack: help libdw find the split debug for ld-musl on Alpine. # Otherwise, the test fails even though we do everything right. - ld_musl_debug = list(Path("/usr/lib/debug/lib/").glob("ld-musl*.debug")) - if ld_musl_debug != []: # pragma: no cover - (relocated_bundle / "app" / ".debug").symlink_to("/usr/lib/debug/lib") + debuginfos = list(Path("/usr/lib/debug/lib/").glob("ld-musl*.debug")) + for debuginfo in debuginfos: # pragma: no cover + (relocated_bundle / "app" / debuginfo.name).symlink_to(debuginfo) + + # Apply the same hack to find the libpython debug info, too. + debuginfos = list(Path("/usr/lib/debug/usr/lib/").glob("libpython*.debug")) + for debuginfo in debuginfos: # pragma: no cover + (relocated_bundle / "app" / debuginfo.name).symlink_to(debuginfo) binary_dependencies = ":".join( {str(file.parent) for file in relocated_bundle.glob("**/*.so*")} @@ -103,7 +114,7 @@ def test_single_thread_stack_for_relocated_core( assert functions == ["", "first_func", "second_func", "third_func"] *line_numbers, last_line = [frame.code.location.lineno for frame in frames] - assert line_numbers == [20, 6, 10] + assert line_numbers == [19, 6, 10] assert last_line in {16, 17} assert thread.native_frames 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 diff --git a/tests/integration/test_smoke.py b/tests/integration/test_smoke.py index 47a02fa9..a370b40d 100644 --- a/tests/integration/test_smoke.py +++ b/tests/integration/test_smoke.py @@ -20,20 +20,41 @@ elif sys.version_info < (3, 11): # pragma: no cover STACK_METHODS = (StackMethod.SYMBOLS, StackMethod.ELF_DATA, StackMethod.HEAP) CORE_STACK_METHODS = (StackMethod.SYMBOLS, StackMethod.ELF_DATA) -else: # pragma: no cover +elif sys.version_info < (3, 13): # pragma: no cover STACK_METHODS = (StackMethod.SYMBOLS, StackMethod.ELF_DATA) CORE_STACK_METHODS = (StackMethod.SYMBOLS, StackMethod.ELF_DATA) +else: # pragma: no cover + STACK_METHODS = ( + StackMethod.DEBUG_OFFSETS, + StackMethod.SYMBOLS, + StackMethod.ELF_DATA, + ) + CORE_STACK_METHODS = ( + StackMethod.DEBUG_OFFSETS, + StackMethod.SYMBOLS, + StackMethod.ELF_DATA, + ) @pytest.mark.parametrize("method", STACK_METHODS) @pytest.mark.parametrize("blocking", [True, False], ids=["blocking", "non-blocking"]) -def test_simple_execution(method, blocking, tmpdir): +@pytest.mark.parametrize( + "use_process_vm_readv", [True, False], ids=["process_vm_readv", "/proc/PID/mem"] +) +def test_simple_execution(method, blocking, use_process_vm_readv, tmpdir, monkeypatch): """Test that we can retrieve the thread state of a single process. This test is specially useful to run under valgrind or similar tools and to quickly check that the very basic functionality works as expected. """ + # GIVEN + env_var = "_PYSTACK_NO_PROCESS_VM_READV" + if use_process_vm_readv: + monkeypatch.delenv(env_var, raising=False) + else: + monkeypatch.setenv(env_var, "1") + # WHEN with spawn_child_process( sys.executable, TEST_SINGLE_THREAD_FILE, tmpdir diff --git a/tests/integration/test_subinterpreters.py b/tests/integration/test_subinterpreters.py new file mode 100644 index 00000000..1c833ad6 --- /dev/null +++ b/tests/integration/test_subinterpreters.py @@ -0,0 +1,383 @@ +import io +from collections import Counter +from contextlib import redirect_stdout +from pathlib import Path +from typing import Dict +from typing import List +from typing import Set + +import pytest + +from pystack.engine import NativeReportingMode +from pystack.engine import StackMethod +from pystack.engine import get_process_threads +from pystack.engine import get_process_threads_for_core +from pystack.traceback_formatter import print_threads +from pystack.types import NativeFrame +from pystack.types import frame_type +from tests.utils import ALL_PYTHONS_THAT_SUPPORT_SUBINTERPRETERS +from tests.utils import generate_core_file +from tests.utils import spawn_child_process + +NUM_INTERPRETERS = 3 +NUM_INTERPRETERS_WITH_THREADS = 2 +NUM_THREADS_PER_SUBINTERPRETER = 2 + +SCRIPTS_DIR = Path(__file__).parent +SUBINTERPRETERS_SHIM = SCRIPTS_DIR / "subinterpreters_shim.py" +TEST_SUBINTERPRETERS = SCRIPTS_DIR / "subinterpreters_program.py" +TEST_SUBINTERPRETERS_WITH_THREADS = ( + SCRIPTS_DIR / "subinterpreters_with_threads_program.py" +) +TEST_SUBINTERPRETERS_NESTED_SAME_THREAD = ( + SCRIPTS_DIR / "subinterpreters_nested_same_thread_program.py" +) +TEST_SUBINTERPRETERS_TWO_CHAINS = SCRIPTS_DIR / "subinterpreters_two_chains_program.py" + + +def _collect_threads( + python_executable: Path, + tmpdir: Path, + native_mode: NativeReportingMode = NativeReportingMode.OFF, + test_file: Path = TEST_SUBINTERPRETERS, +): + script = tmpdir / "subinterpreters_program.py" + script.write_bytes(test_file.read_bytes()) + (tmpdir / "subinterpreters_shim.py").write_bytes(SUBINTERPRETERS_SHIM.read_bytes()) + + with spawn_child_process( + str(python_executable), str(script), tmpdir + ) as child_process: + return list( + get_process_threads( + child_process.pid, + stop_process=True, + native_mode=native_mode, + method=StackMethod.AUTO, + ) + ) + + +def _assert_interpreter_headers( + threads, + native_mode: NativeReportingMode, + interpreter_ids, +) -> str: + output = io.StringIO() + with redirect_stdout(output): + print_threads(threads, native_mode=native_mode) + + result = output.getvalue() + assert "In the main interpreter" in result + for interpreter_id in interpreter_ids: + if interpreter_id == 0: + continue + assert f"In interpreter {interpreter_id}" in result + return result + + +def _count_threads_by_interpreter(threads): + return dict( + Counter( + thread.interpreter_id + for thread in threads + if thread.interpreter_id is not None + ) + ) + + +def _interpreter_ids(threads) -> Set[int]: + return { + thread.interpreter_id for thread in threads if thread.interpreter_id is not None + } + + +def _assert_subinterpreter_coverage(threads) -> Set[int]: + interpreter_ids = _interpreter_ids(threads) + assert 0 in interpreter_ids + assert len(interpreter_ids) == NUM_INTERPRETERS + 1 + return interpreter_ids + + +def _assert_native_eval_symbols(threads) -> None: + eval_frames = [ + frame + for thread in threads + for frame in thread.native_frames + if frame_type(frame, thread.python_version) == NativeFrame.FrameType.EVAL + ] + assert eval_frames + assert all("?" not in frame.symbol for frame in eval_frames) + if any(frame.linenumber == 0 for frame in eval_frames): # pragma: no cover + assert all(frame.linenumber == 0 for frame in eval_frames) + assert all(frame.path == "???" for frame in eval_frames) + else: # pragma: no cover + assert all(frame.linenumber != 0 for frame in eval_frames) + assert any(frame.path and "?" not in frame.path for frame in eval_frames) + + +def _assert_mergeable_same_tid_groups(threads) -> bool: + groups: Dict[int, List] = {} + for thread in threads: + groups.setdefault(thread.tid, []).append(thread) + + found_shared_tid = False + for group in groups.values(): + interpreter_ids = { + thread.interpreter_id + for thread in group + if thread.interpreter_id is not None + } + if len(group) < 2 or len(interpreter_ids) < 2: + continue + found_shared_tid = True + for thread in group: + eval_frames = [ + frame + for frame in thread.native_frames + if frame_type(frame, thread.python_version) + == NativeFrame.FrameType.EVAL + ] + entry_count = sum(frame.is_entry for frame in thread.all_frames) + assert len(eval_frames) == entry_count + return found_shared_tid + + +def _shared_tid_groups_with_min_interpreters(threads, min_interpreters): + groups = {} + for thread in threads: + groups.setdefault(thread.tid, []).append(thread) + + matching = [] + for tid, group in groups.items(): + interpreter_ids = { + thread.interpreter_id + for thread in group + if thread.interpreter_id is not None + } + if len(interpreter_ids) >= min_interpreters: + matching.append((tid, group)) + return matching + + +def _assert_strict_native_eval_symbols_for_group(group) -> None: + for thread in group: + eval_frames = [ + frame + for frame in thread.native_frames + if frame_type(frame, thread.python_version) == NativeFrame.FrameType.EVAL + ] + assert eval_frames + assert all("?" not in frame.symbol for frame in eval_frames) + if any(frame.linenumber == 0 for frame in eval_frames): + assert all(frame.linenumber == 0 for frame in eval_frames) + assert all(frame.path == "???" for frame in eval_frames) + else: + assert all(frame.linenumber != 0 for frame in eval_frames) + assert any(frame.path and "?" not in frame.path for frame in eval_frames) + + +@ALL_PYTHONS_THAT_SUPPORT_SUBINTERPRETERS +def test_subinterpreters(python, tmp_path): + _, python_executable = python + + threads = _collect_threads( + python_executable=python_executable, + tmpdir=tmp_path, + native_mode=NativeReportingMode.OFF, + ) + + interpreter_ids = _assert_subinterpreter_coverage(threads) + assert all(not thread.native_frames for thread in threads) + _assert_interpreter_headers( + threads=threads, + native_mode=NativeReportingMode.OFF, + interpreter_ids=interpreter_ids, + ) + + +@ALL_PYTHONS_THAT_SUPPORT_SUBINTERPRETERS +@pytest.mark.parametrize( + "native_mode", + [ + NativeReportingMode.PYTHON, + NativeReportingMode.LAST, + NativeReportingMode.ALL, + ], + ids=["python", "last", "all"], +) +def test_subinterpreters_with_native(python, tmp_path, native_mode): + _, python_executable = python + + threads = _collect_threads( + python_executable=python_executable, + tmpdir=tmp_path, + native_mode=native_mode, + ) + + interpreter_ids = _assert_subinterpreter_coverage(threads) + assert any(thread.native_frames for thread in threads) + _assert_native_eval_symbols(threads) + + output = _assert_interpreter_headers( + threads=threads, + native_mode=native_mode, + interpreter_ids=interpreter_ids, + ) + assert "(C)" in output or "Unable to merge native stack" in output + + +@ALL_PYTHONS_THAT_SUPPORT_SUBINTERPRETERS +def test_subinterpreters_many_threads_with_native(python, tmp_path): + _, python_executable = python + + threads = _collect_threads( + python_executable=python_executable, + tmpdir=tmp_path, + native_mode=NativeReportingMode.PYTHON, + test_file=TEST_SUBINTERPRETERS_WITH_THREADS, + ) + + interpreter_ids = _interpreter_ids(threads) + assert 0 in interpreter_ids + assert len(interpreter_ids) == NUM_INTERPRETERS_WITH_THREADS + 1 + + counts_by_interpreter = _count_threads_by_interpreter(threads) + assert all( + counts_by_interpreter.get(interpreter_id, 0) >= 1 + for interpreter_id in interpreter_ids + ) + # At least one sub-interpreter should expose multiple Python threads. + assert any( + count > 1 + for interpreter_id, count in counts_by_interpreter.items() + if interpreter_id != 0 + ) + + assert any(thread.native_frames for thread in threads) + _assert_native_eval_symbols(threads) + + +@ALL_PYTHONS_THAT_SUPPORT_SUBINTERPRETERS +def test_subinterpreters_nested_same_thread_with_native(python, tmp_path): + _, python_executable = python + + threads = _collect_threads( + python_executable=python_executable, + tmpdir=tmp_path, + native_mode=NativeReportingMode.PYTHON, + test_file=TEST_SUBINTERPRETERS_NESTED_SAME_THREAD, + ) + + assert any(thread.native_frames for thread in threads) + _assert_native_eval_symbols(threads) + + has_shared_tid = _assert_mergeable_same_tid_groups(threads) + assert has_shared_tid + + output = _assert_interpreter_headers( + threads=threads, + native_mode=NativeReportingMode.PYTHON, + interpreter_ids=_interpreter_ids(threads), + ) + assert ( + "Unable to merge native stack due to insufficient native information" + not in output + ) + + +@ALL_PYTHONS_THAT_SUPPORT_SUBINTERPRETERS +def test_subinterpreters_two_threads_three_per_thread_with_native(python, tmp_path): + _, python_executable = python + + threads = _collect_threads( + python_executable=python_executable, + tmpdir=tmp_path, + native_mode=NativeReportingMode.PYTHON, + test_file=TEST_SUBINTERPRETERS_TWO_CHAINS, + ) + + groups = _shared_tid_groups_with_min_interpreters(threads, min_interpreters=3) + assert len(groups) >= 2 + + for _, group in groups: + _assert_strict_native_eval_symbols_for_group(group) + for thread in group: + eval_frames = [ + frame + for frame in thread.native_frames + if frame_type(frame, thread.python_version) + == NativeFrame.FrameType.EVAL + ] + entry_count = sum(frame.is_entry for frame in thread.all_frames) + assert len(eval_frames) == entry_count + assert len(eval_frames) > 0 + + output = _assert_interpreter_headers( + threads=threads, + native_mode=NativeReportingMode.PYTHON, + interpreter_ids=_interpreter_ids(threads), + ) + assert ( + "Unable to merge native stack due to insufficient native information" + not in output + ) + + +@ALL_PYTHONS_THAT_SUPPORT_SUBINTERPRETERS +def test_subinterpreters_for_core(python, tmp_path): + _, python_executable = python + + with generate_core_file( + python_executable, TEST_SUBINTERPRETERS, tmp_path + ) as core_file: + threads = list( + get_process_threads_for_core( + core_file, + python_executable, + native_mode=NativeReportingMode.OFF, + ) + ) + + interpreter_ids = _assert_subinterpreter_coverage(threads) + assert all(not thread.native_frames for thread in threads) + _assert_interpreter_headers( + threads=threads, + native_mode=NativeReportingMode.OFF, + interpreter_ids=interpreter_ids, + ) + + +@ALL_PYTHONS_THAT_SUPPORT_SUBINTERPRETERS +@pytest.mark.parametrize( + "native_mode", + [ + NativeReportingMode.PYTHON, + NativeReportingMode.LAST, + NativeReportingMode.ALL, + ], + ids=["python", "last", "all"], +) +def test_subinterpreters_for_core_with_native(python, tmp_path, native_mode): + _, python_executable = python + + with generate_core_file( + python_executable, TEST_SUBINTERPRETERS, tmp_path + ) as core_file: + threads = list( + get_process_threads_for_core( + core_file, + python_executable, + native_mode=native_mode, + ) + ) + + interpreter_ids = _assert_subinterpreter_coverage(threads) + assert any(thread.native_frames for thread in threads) + _assert_native_eval_symbols(threads) + output = _assert_interpreter_headers( + threads=threads, + native_mode=native_mode, + interpreter_ids=interpreter_ids, + ) + assert "(C)" in output or "Unable to merge native stack" in output diff --git a/tests/integration/testext/main.py b/tests/integration/testext/main.py new file mode 100644 index 00000000..20dcd34f --- /dev/null +++ b/tests/integration/testext/main.py @@ -0,0 +1,19 @@ +import sys + +import testext + + +def first_func(): + second_func() + + +def second_func(): + third_func() + + +def third_func(): + # Blocks forever inside a C signal handler, after announcing we're ready. + testext.signal_readiness_then_block(sys.argv[1]) + + +first_func() diff --git a/tests/integration/testext/setup.py b/tests/integration/testext/setup.py new file mode 100644 index 00000000..0c180823 --- /dev/null +++ b/tests/integration/testext/setup.py @@ -0,0 +1,23 @@ +import os + +try: + from distutils.core import Extension + from distutils.core import setup +except ImportError: + from setuptools import Extension # type: ignore[no-redef] + from setuptools import setup # type: ignore[no-redef] + +ROOT = os.path.realpath(os.path.dirname(__file__)) + +setup( + name="testext", + version="0.0", + ext_modules=[ + Extension( + "testext", + language="c++", + sources=[os.path.join(ROOT, "testext.cpp")], + ), + ], + zip_safe=False, +) diff --git a/tests/integration/testext/testext.cpp b/tests/integration/testext/testext.cpp new file mode 100644 index 00000000..f5e4ae7b --- /dev/null +++ b/tests/integration/testext/testext.cpp @@ -0,0 +1,90 @@ +#define PY_SSIZE_T_CLEAN +#include + +#include +#include +#include +#include +#include + +static int fifo_fd = -1; + +void +writeAll(const char* buf, size_t len) +{ + while (len > 0) { + ssize_t written = write(fifo_fd, buf, len); + if (written <= 0) { + if (written < 0 && errno == EINTR) { + continue; + } + Py_FatalError(strerror(errno)); + } + buf += written; + len -= (size_t)written; + } +} + +// Announce that we're ready and then block forever. +extern "C" void +signalReadinessThenBlock(int) +{ + const char token[] = "ready"; + writeAll(token, sizeof(token) - 1); + close(fifo_fd); // Caller reads til EOF, so the FIFO must be closed. + pause(); +} + +PyObject* +signal_readiness_then_block(PyObject*, PyObject* args) +{ + const char* path; + if (!PyArg_ParseTuple(args, "s", &path)) { + return NULL; + } + + fifo_fd = open(path, O_WRONLY); + if (fifo_fd < 0) { + return PyErr_SetFromErrno(PyExc_OSError); + } + + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = &signalReadinessThenBlock; + sigemptyset(&action.sa_mask); + if (sigaction(SIGUSR1, &action, NULL) != 0) { + PyErr_SetFromErrno(PyExc_OSError); + return NULL; + } + + raise(SIGUSR1); + Py_FatalError("the signal handler unexpectedly returned"); +} + +static PyMethodDef methods[] = { + {"signal_readiness_then_block", + signal_readiness_then_block, + METH_VARARGS, + "Write \"ready\" to a FIFO from a signal handler, then block forever"}, + {NULL, NULL, 0, NULL}, +}; + +#if PY_MAJOR_VERSION >= 3 +static struct PyModuleDef moduledef = {PyModuleDef_HEAD_INIT, "testext", "", -1, methods}; + +PyMODINIT_FUNC +PyInit_testext(void) +{ + PyObject* mod = PyModule_Create(&moduledef); +# ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(mod, Py_MOD_GIL_NOT_USED); +# endif + return mod; +} +#else +PyMODINIT_FUNC +inittestext(void) +{ + Py_InitModule("testext", methods); +} +#endif diff --git a/tests/unit/test_color.py b/tests/unit/test_color.py index 639df566..7473f461 100644 --- a/tests/unit/test_color.py +++ b/tests/unit/test_color.py @@ -128,9 +128,12 @@ def test_colored_with_environment_variable(): def test_colored_without_environment_variable(): - with patch( - "pystack.colors.format_colored", return_value="formatted red" - ) as format_mock, patch("pystack.colors._is_stdout_a_tty", return_value=True): + with ( + patch( + "pystack.colors.format_colored", return_value="formatted red" + ) as format_mock, + patch("pystack.colors._is_stdout_a_tty", return_value=True), + ): output = colored("some_text", "red") format_mock.assert_called_once() assert output == "formatted red" diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 86f55cc6..d8e9d01c 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -2,7 +2,7 @@ from pathlib import Path from textwrap import dedent from unittest.mock import Mock -from unittest.mock import call +from unittest.mock import mock_open from unittest.mock import patch import pytest @@ -26,6 +26,7 @@ from pystack.errors import InvalidPythonProcess from pystack.errors import MissingExecutableMaps from pystack.errors import NotEnoughInformation +from pystack.maps import VirtualMap def test_error_message_wih_permission_error_text(): @@ -186,12 +187,10 @@ def test_process_remote_default(): # WHEN - with patch( - "pystack.__main__.get_process_threads" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv + with ( + patch("pystack.__main__.get_process_threads") as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), ): get_process_threads_mock.return_value = threads main() @@ -205,7 +204,7 @@ def test_process_remote_default(): locals=False, method=StackMethod.AUTO, ) - assert print_thread_mock.mock_calls == [call(thread, False) for thread in threads] + print_threads_mock.assert_called_once_with(threads, NativeReportingMode.OFF) def test_process_remote_no_block(): @@ -217,12 +216,10 @@ def test_process_remote_no_block(): # WHEN - with patch( - "pystack.__main__.get_process_threads" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv + with ( + patch("pystack.__main__.get_process_threads") as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), ): get_process_threads_mock.return_value = threads main() @@ -236,24 +233,30 @@ def test_process_remote_no_block(): locals=False, method=StackMethod.AUTO, ) - assert print_thread_mock.mock_calls == [call(thread, False) for thread in threads] + print_threads_mock.assert_called_once_with(threads, NativeReportingMode.OFF) -def test_process_remote_native(): +@pytest.mark.parametrize( + "argument, mode", + [ + ["--native", NativeReportingMode.PYTHON], + ["--native-all", NativeReportingMode.ALL], + ["--native-last", NativeReportingMode.LAST], + ], +) +def test_process_remote_native(argument, mode): # GIVEN - argv = ["pystack", "remote", "31", "--native"] + argv = ["pystack", "remote", "31", argument] threads = [Mock(), Mock(), Mock()] # WHEN - with patch( - "pystack.__main__.get_process_threads" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv + with ( + patch("pystack.__main__.get_process_threads") as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), ): get_process_threads_mock.return_value = threads main() @@ -263,11 +266,11 @@ def test_process_remote_native(): get_process_threads_mock.assert_called_with( 31, stop_process=True, - native_mode=NativeReportingMode.PYTHON, + native_mode=mode, locals=False, method=StackMethod.AUTO, ) - assert print_thread_mock.mock_calls == [call(thread, True) for thread in threads] + print_threads_mock.assert_called_once_with(threads, mode) def test_process_remote_locals(): @@ -279,12 +282,10 @@ def test_process_remote_locals(): # WHEN - with patch( - "pystack.__main__.get_process_threads" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv + with ( + patch("pystack.__main__.get_process_threads") as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), ): get_process_threads_mock.return_value = threads main() @@ -298,7 +299,7 @@ def test_process_remote_locals(): locals=True, method=StackMethod.AUTO, ) - assert print_thread_mock.mock_calls == [call(thread, False) for thread in threads] + print_threads_mock.assert_called_once_with(threads, NativeReportingMode.OFF) def test_process_remote_native_no_block(capsys): @@ -310,12 +311,10 @@ def test_process_remote_native_no_block(capsys): # WHEN - with patch( - "pystack.__main__.get_process_threads" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv + with ( + patch("pystack.__main__.get_process_threads") as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), ): get_process_threads_mock.return_value = threads # THEN @@ -324,7 +323,7 @@ def test_process_remote_native_no_block(capsys): main() get_process_threads_mock.assert_not_called() - print_thread_mock.assert_not_called() + print_threads_mock.assert_not_called() def test_process_remote_exhaustive(): @@ -336,12 +335,10 @@ def test_process_remote_exhaustive(): # WHEN - with patch( - "pystack.__main__.get_process_threads" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv + with ( + patch("pystack.__main__.get_process_threads") as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), ): get_process_threads_mock.return_value = threads main() @@ -355,7 +352,7 @@ def test_process_remote_exhaustive(): locals=False, method=StackMethod.ALL, ) - assert print_thread_mock.mock_calls == [call(thread, False) for thread in threads] + print_threads_mock.assert_called_once_with(threads, NativeReportingMode.OFF) @pytest.mark.parametrize( @@ -368,14 +365,11 @@ def test_process_remote_error(exception, exval, capsys): # WHEN - with patch( - "pystack.__main__.get_process_threads" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv - ), patch( - "pathlib.Path.exists", return_value=True + with ( + patch("pystack.__main__.get_process_threads") as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), ): get_process_threads_mock.side_effect = exception("Oh no!") with pytest.raises(SystemExit) as excinfo: @@ -385,7 +379,7 @@ def test_process_remote_error(exception, exval, capsys): # THEN get_process_threads_mock.assert_called_once() - print_thread_mock.assert_not_called() + print_threads_mock.assert_not_called() capture = capsys.readouterr() assert "Oh no!" in capture.err @@ -399,19 +393,17 @@ def test_process_core_default_without_executable(): # WHEN - with patch( - "pystack.__main__.get_process_threads_for_core" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv - ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.is_elf", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ) as core_file_analizer_mock: + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), + patch("pystack.__main__.CoreFileAnalyzer") as core_file_analizer_mock, + ): core_file_analizer_mock().extract_executable.return_value = ( "extracted_executable" ) @@ -428,7 +420,56 @@ def test_process_core_default_without_executable(): locals=False, method=StackMethod.AUTO, ) - assert print_thread_mock.mock_calls == [call(thread, False) for thread in threads] + print_threads_mock.assert_called_once_with(threads, NativeReportingMode.OFF) + + +def test_process_core_default_gzip_without_executable(): + # GIVEN + + argv = ["pystack", "core", "corefile.gz"] + + threads = [Mock(), Mock(), Mock()] + + temp_mock_file = Mock() + temp_mock_file.name = Path("/tmp/file") + temp_file_context_mock = Mock() + temp_file_context_mock.__enter__ = Mock(return_value=temp_mock_file) + temp_file_context_mock.__exit__ = Mock(return_value=None) + + # WHEN + + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.is_elf", return_value=True), + patch("builtins.open", mock_open(read_data=b"\x1f\x8b")), + patch("gzip.open", mock_open(read_data=b"")) as gzip_open_mock, + patch("tempfile.NamedTemporaryFile", return_value=temp_file_context_mock), + patch("pystack.__main__.CoreFileAnalyzer") as core_file_analizer_mock, + ): + core_file_analizer_mock().extract_executable.return_value = ( + "extracted_executable" + ) + get_process_threads_mock.return_value = threads + + main() + + # THEN + + get_process_threads_mock.assert_called_with( + Path("/tmp/file"), + Path("extracted_executable"), + library_search_path="", + native_mode=NativeReportingMode.OFF, + locals=False, + method=StackMethod.AUTO, + ) + print_threads_mock.assert_called_once_with(threads, NativeReportingMode.OFF) + gzip_open_mock.assert_called_with(Path("corefile.gz"), "rb") def test_process_core_default_without_executable_and_executable_does_not_exist(capsys): @@ -438,16 +479,17 @@ def test_process_core_default_without_executable_and_executable_does_not_exist(c # WHEN - with patch("sys.argv", argv), patch( - "pathlib.Path.exists" - ) as path_exists_mock, patch( - "pystack.__main__.CoreFileAnalyzer" - ) as core_file_analizer_mock: + with ( + patch("sys.argv", argv), + patch("pathlib.Path.exists") as path_exists_mock, + patch("pystack.__main__.is_gzip", return_value=False), + patch("pystack.__main__.CoreFileAnalyzer") as core_file_analizer_mock, + ): core_file_analizer_mock().extract_executable.return_value = ( "extracted_executable" ) # THEN - path_exists_mock.side_effect = [True, False] + path_exists_mock.side_effect = [True, False, False, False] with pytest.raises(SystemExit): main() @@ -465,14 +507,15 @@ def test_process_core_executable_not_elf_file(capsys): # WHEN - with patch( - "pystack.__main__.get_process_threads_for_core" - ) as get_process_threads_mock, patch("sys.argv", argv), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "pystack.__main__.is_elf", return_value=False + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch("pystack.__main__.is_elf", return_value=False), + patch("pystack.__main__.is_gzip", return_value=False), ): get_process_threads_mock.return_value = threads with pytest.raises(SystemExit): @@ -491,18 +534,16 @@ def test_process_core_default_with_executable(): # WHEN - with patch( - "pystack.__main__.get_process_threads_for_core" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv - ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "pystack.__main__.is_elf", return_value=True + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), ): get_process_threads_mock.return_value = threads main() @@ -517,7 +558,7 @@ def test_process_core_default_with_executable(): locals=False, method=StackMethod.AUTO, ) - assert print_thread_mock.mock_calls == [call(thread, False) for thread in threads] + print_threads_mock.assert_called_once_with(threads, NativeReportingMode.OFF) @pytest.mark.parametrize( @@ -525,6 +566,7 @@ def test_process_core_default_with_executable(): [ ["--native", NativeReportingMode.PYTHON], ["--native-all", NativeReportingMode.ALL], + ["--native-last", NativeReportingMode.LAST], ], ) def test_process_core_native(argument, mode): @@ -536,18 +578,16 @@ def test_process_core_native(argument, mode): # WHEN - with patch( - "pystack.__main__.get_process_threads_for_core" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv - ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "pystack.__main__.is_elf", return_value=True + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), ): get_process_threads_mock.return_value = threads main() @@ -562,7 +602,7 @@ def test_process_core_native(argument, mode): locals=False, method=StackMethod.AUTO, ) - assert print_thread_mock.mock_calls == [call(thread, True) for thread in threads] + print_threads_mock.assert_called_once_with(threads, mode) def test_process_core_locals(): @@ -574,18 +614,16 @@ def test_process_core_locals(): # WHEN - with patch( - "pystack.__main__.get_process_threads_for_core" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv - ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "pystack.__main__.is_elf", return_value=True + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), ): get_process_threads_mock.return_value = threads main() @@ -600,7 +638,7 @@ def test_process_core_locals(): locals=True, method=StackMethod.AUTO, ) - assert print_thread_mock.mock_calls == [call(thread, False) for thread in threads] + print_threads_mock.assert_called_once_with(threads, NativeReportingMode.OFF) def test_process_core_with_search_path(): @@ -619,18 +657,16 @@ def test_process_core_with_search_path(): # WHEN - with patch( - "pystack.__main__.get_process_threads_for_core" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv - ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "pystack.__main__.is_elf", return_value=True + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), ): get_process_threads_mock.return_value = threads main() @@ -645,7 +681,7 @@ def test_process_core_with_search_path(): locals=False, method=StackMethod.AUTO, ) - assert print_thread_mock.mock_calls == [call(thread, False) for thread in threads] + print_threads_mock.assert_called_once_with(threads, NativeReportingMode.OFF) def test_process_core_with_search_root(): @@ -657,26 +693,27 @@ def test_process_core_with_search_root(): # WHEN - with patch( - "pystack.__main__.get_process_threads_for_core" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv - ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "pathlib.Path.glob", return_value=[Path("foo/lel.so"), Path("bar/lel.so")] - ), patch( - "os.path.isdir", - return_value=True, - ), patch( - "os.access", - return_value=True, - ), patch( - "pystack.__main__.is_elf", return_value=True + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch( + "pathlib.Path.glob", return_value=[Path("foo/lel.so"), Path("bar/lel.so")] + ), + patch( + "os.path.isdir", + return_value=True, + ), + patch( + "os.access", + return_value=True, + ), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), ): get_process_threads_mock.return_value = threads main() @@ -691,7 +728,7 @@ def test_process_core_with_search_root(): locals=False, method=StackMethod.AUTO, ) - assert print_thread_mock.mock_calls == [call(thread, False) for thread in threads] + print_threads_mock.assert_called_once_with(threads, NativeReportingMode.OFF) def test_process_core_with_not_readable_search_root(): @@ -701,17 +738,18 @@ def test_process_core_with_not_readable_search_root(): # WHEN - with patch("pystack.__main__.get_process_threads_for_core"), patch( - "pystack.__main__.print_thread" - ), patch("sys.argv", argv), patch("pathlib.Path.exists", return_value=True), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "os.path.isdir", - return_value=True, - ), patch( - "os.access", return_value=False - ), patch( - "pystack.__main__.is_elf", return_value=True + with ( + patch("pystack.__main__.get_process_threads_for_core"), + patch("pystack.__main__.print_threads"), + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch( + "os.path.isdir", + return_value=True, + ), + patch("os.access", return_value=False), + patch("pystack.__main__.is_elf", return_value=True), ): # THEN with pytest.raises(SystemExit): @@ -725,13 +763,16 @@ def test_process_core_with_invalid_search_root(): # WHEN - with patch("pystack.__main__.get_process_threads_for_core"), patch( - "pystack.__main__.print_thread" - ), patch("sys.argv", argv), patch("pathlib.Path.exists", return_value=True), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "os.path.isdir", - return_value=False, + with ( + patch("pystack.__main__.get_process_threads_for_core"), + patch("pystack.__main__.print_threads"), + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch( + "os.path.isdir", + return_value=False, + ), ): # THEN with pytest.raises(SystemExit): @@ -748,14 +789,13 @@ def test_process_core_corefile_does_not_exit(): def path_exists(what): return what != Path("corefile") - with patch( - "pystack.__main__.get_process_threads_for_core" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv - ), patch.object( - Path, "exists", path_exists + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), + patch.object(Path, "exists", path_exists), ): # THEN @@ -765,7 +805,7 @@ def path_exists(what): # THEN get_process_threads_mock.assert_not_called() - print_thread_mock.assert_not_called() + print_threads_mock.assert_not_called() def test_process_core_executable_does_not_exit(): @@ -780,14 +820,14 @@ def does_exit(what): return False return True - with patch( - "pystack.__main__.get_process_threads_for_core" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv - ), patch.object( - Path, "exists", does_exit + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("pystack.__main__.is_gzip", return_value=False), + patch("sys.argv", argv), + patch.object(Path, "exists", does_exit), ): # THEN with pytest.raises(SystemExit): @@ -796,7 +836,7 @@ def does_exit(what): # THEN get_process_threads_mock.assert_not_called() - print_thread_mock.assert_not_called() + print_threads_mock.assert_not_called() @pytest.mark.parametrize( @@ -809,18 +849,16 @@ def test_process_core_error(exception, exval, capsys): # WHEN - with patch( - "pystack.__main__.get_process_threads_for_core" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv - ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "pystack.__main__.is_elf", return_value=True + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), ): # THEN get_process_threads_mock.side_effect = exception("Oh no!") @@ -831,7 +869,7 @@ def test_process_core_error(exception, exval, capsys): # THEN get_process_threads_mock.assert_called_once() - print_thread_mock.assert_not_called() + print_threads_mock.assert_not_called() capture = capsys.readouterr() assert "Oh no!" in capture.err @@ -844,18 +882,16 @@ def test_process_core_exhaustive(): threads = [Mock(), Mock(), Mock()] # WHEN - with patch( - "pystack.__main__.get_process_threads_for_core" - ) as get_process_threads_mock, patch( - "pystack.__main__.print_thread" - ) as print_thread_mock, patch( - "sys.argv", argv - ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "pystack.__main__.is_elf", return_value=True + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("pystack.__main__.print_threads") as print_threads_mock, + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), ): get_process_threads_mock.return_value = threads main() @@ -870,7 +906,7 @@ def test_process_core_exhaustive(): locals=False, method=StackMethod.ALL, ) - assert print_thread_mock.mock_calls == [call(thread, False) for thread in threads] + print_threads_mock.assert_called_once_with(threads, NativeReportingMode.OFF) def test_default_colored_output(): @@ -881,9 +917,12 @@ def test_default_colored_output(): # WHEN - with patch("pystack.__main__.get_process_threads"), patch( - "pystack.__main__.print_thread" - ), patch("sys.argv", argv), patch("os.environ", environ): + with ( + patch("pystack.__main__.get_process_threads"), + patch("pystack.__main__.print_threads"), + patch("sys.argv", argv), + patch("os.environ", environ), + ): main() # THEN @@ -899,9 +938,12 @@ def test_nocolor_output(): # WHEN - with patch("pystack.__main__.get_process_threads"), patch( - "pystack.__main__.print_thread" - ), patch("sys.argv", argv), patch("os.environ", environ): + with ( + patch("pystack.__main__.get_process_threads"), + patch("pystack.__main__.print_threads"), + patch("sys.argv", argv), + patch("os.environ", environ), + ): main() # THEN @@ -917,9 +959,12 @@ def test_nocolor_output_at_the_front_for_process(): # WHEN - with patch("pystack.__main__.get_process_threads"), patch( - "pystack.__main__.print_thread" - ), patch("sys.argv", argv), patch("os.environ", environ): + with ( + patch("pystack.__main__.get_process_threads"), + patch("pystack.__main__.print_threads"), + patch("sys.argv", argv), + patch("os.environ", environ), + ): main() # THEN @@ -934,14 +979,15 @@ def test_nocolor_output_at_the_front_for_core(): environ = {} # WHEN - with patch("pystack.__main__.get_process_threads_for_core"), patch( - "pystack.__main__.print_thread" - ), patch("sys.argv", argv), patch("os.environ", environ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "pystack.__main__.is_elf", return_value=True + with ( + patch("pystack.__main__.get_process_threads_for_core"), + patch("pystack.__main__.print_threads"), + patch("sys.argv", argv), + patch("os.environ", environ), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), ): main() @@ -958,14 +1004,15 @@ def test_global_options_can_be_placed_at_any_point(option): environ = {} # WHEN - with patch("pystack.__main__.get_process_threads_for_core"), patch( - "pystack.__main__.print_thread" - ), patch("sys.argv", argv), patch("os.environ", environ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "pystack.__main__.is_elf", return_value=True + with ( + patch("pystack.__main__.get_process_threads_for_core"), + patch("pystack.__main__.print_threads"), + patch("sys.argv", argv), + patch("os.environ", environ), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), ): # THEN @@ -979,15 +1026,15 @@ def test_verbose_as_global_options_sets_correctly_the_logger(): environ = {} # WHEN - with patch("pystack.__main__.get_process_threads"), patch( - "pystack.__main__.print_thread" - ), patch("sys.argv", argv), patch("os.environ", environ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ), patch( - "pystack.__main__.logging.basicConfig" - ) as logger_mock: + with ( + patch("pystack.__main__.get_process_threads"), + patch("pystack.__main__.print_threads"), + patch("sys.argv", argv), + patch("os.environ", environ), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer"), + patch("pystack.__main__.logging.basicConfig") as logger_mock, + ): # THEN main() @@ -1107,15 +1154,13 @@ def test_format_psinfo(): # THEN - expected = dedent( - """\ + expected = dedent("""\ Core file information: state: R zombie: True niceness: 0 pid: 75639 ppid: 1 sid: 1 uid: 0 gid: 0 pgrp: 75639 executable: a.out arguments: ./a.out - """ - ) + """) assert expected.strip() == result.strip() @@ -1128,15 +1173,15 @@ def test_process_core_does_not_crash_if_core_analyzer_fails(method): # WHEN / THEN - with patch("pystack.__main__.get_process_threads_for_core"), patch( - "pystack.__main__.print_thread" - ), patch("pystack.__main__.is_elf", return_value=True), patch( - "sys.argv", argv - ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ) as core_analyzer_test: + with ( + patch("pystack.__main__.get_process_threads_for_core"), + patch("pystack.__main__.print_threads"), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer") as core_analyzer_test, + ): method = getattr(core_analyzer_test(), method) method.side_effect = Exception("oh no") main() @@ -1153,15 +1198,15 @@ def test_core_file_missing_modules_are_logged(caplog, native): # WHEN - with patch("pystack.__main__.get_process_threads_for_core"), patch( - "pystack.__main__.print_thread" - ), patch("pystack.__main__.is_elf", return_value=True), patch( - "sys.argv", argv - ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ) as core_analyzer_test: + with ( + patch("pystack.__main__.get_process_threads_for_core"), + patch("pystack.__main__.print_threads"), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer") as core_analyzer_test, + ): core_analyzer_test().missing_modules.return_value = ["A", "B"] with caplog.at_level(logging.WARNING): main() @@ -1184,15 +1229,15 @@ def test_core_file_missing_build_ids_are_logged(caplog, native): # WHEN - with patch("pystack.__main__.get_process_threads_for_core"), patch( - "pystack.__main__.print_thread" - ), patch("pystack.__main__.is_elf", return_value=True), patch( - "sys.argv", argv - ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "pystack.__main__.CoreFileAnalyzer" - ) as core_analyzer_test: + with ( + patch("pystack.__main__.get_process_threads_for_core"), + patch("pystack.__main__.print_threads"), + patch("pystack.__main__.is_elf", return_value=True), + patch("pystack.__main__.is_gzip", return_value=False), + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer") as core_analyzer_test, + ): core_analyzer_test().extract_build_ids.return_value = [ ("A", "1", "2"), ("B", "3", "4"), @@ -1213,3 +1258,68 @@ def test_core_file_missing_build_ids_are_logged(caplog, native): else [] ) assert record_messages == expected + + +def test_executable_is_not_elf_uses_the_first_map(): + # GIVEN + argv = ["pystack", "core", "corefile"] + + # WHEN + real_executable = Path("/foo/bar/executable") + + with ( + patch( + "pystack.__main__.get_process_threads_for_core" + ) as get_process_threads_mock, + patch("pystack.__main__.print_threads"), + patch("pystack.__main__.is_elf", lambda x: x == real_executable), + patch("pystack.__main__.is_gzip", return_value=False), + patch("sys.argv", argv), + patch("pathlib.Path.exists", return_value=True), + patch("pystack.__main__.CoreFileAnalyzer") as core_analyzer_test, + ): + core_analyzer_test().extract_executable.return_value = "extracted_executable" + core_analyzer_test().extract_maps.return_value = [ + VirtualMap( + start=0x1000, + end=0x2000, + flags="r-xp", + offset=0, + device="00:00", + inode=0, + filesize=0, + path=None, + ), + VirtualMap( + start=0x2000, + end=0x3000, + flags="rw-p", + offset=0, + device="00:00", + inode=0, + filesize=0, + path=Path("/foo/bar/executable"), + ), + VirtualMap( + start=0x3000, + end=0x4000, + flags="r--p", + offset=0, + device="00:00", + inode=0, + filesize=0, + path=None, + ), + ] + main() + + # THEN + + get_process_threads_mock.assert_called_with( + Path("corefile"), + real_executable, + library_search_path="", + native_mode=NativeReportingMode.OFF, + locals=False, + method=StackMethod.AUTO, + ) diff --git a/tests/unit/test_maps.py b/tests/unit/test_maps.py deleted file mode 100644 index 4f829749..00000000 --- a/tests/unit/test_maps.py +++ /dev/null @@ -1,1267 +0,0 @@ -from pathlib import Path -from unittest.mock import mock_open -from unittest.mock import patch - -import pytest - -from pystack.errors import MissingExecutableMaps -from pystack.errors import ProcessNotFound -from pystack.errors import PystackError -from pystack.maps import VirtualMap -from pystack.maps import _get_base_map -from pystack.maps import _get_bss -from pystack.maps import generate_maps_for_process -from pystack.maps import parse_maps_file_for_binary - - -def test_virtual_map(): - # GIVEN - - map = VirtualMap( - start=0, - end=10, - offset=1234, - device="device", - flags="xrwp", - inode=42, - path=None, - filesize=10, - ) - - # WHEN / THEN - - assert map.contains(5) - assert not map.contains(15) - assert map.is_private() - assert map.is_executable() - assert map.is_readable() - assert map.is_writable() - - -def test_simple_maps_no_such_pid(): - # GIVEN - - with patch("builtins.open", side_effect=FileNotFoundError()): - # WHEN / THEN - with pytest.raises(ProcessNotFound): - list(generate_maps_for_process(1)) - - -def test_simple_maps(): - # GIVEN - - map_text = """ -7f1ac1e2b000-7f1ac1e50000 r--p 00000000 08:12 8398159 /usr/lib/libc-2.31.so - """ - - # WHEN - - with patch("builtins.open", mock_open(read_data=map_text)): - maps = list(generate_maps_for_process(1)) - - # THEN - - assert maps == [ - VirtualMap( - start=139752898736128, - end=139752898887680, - filesize=151552, - offset=0, - device="08:12", - flags="r--p", - inode=8398159, - path=Path("/usr/lib/libc-2.31.so"), - ), - ] - - -def test_maps_with_long_device_numbers(): - # GIVEN - - map_text = """ -7f1ac1e2b000-7f1ac1e50000 r--p 00000000 0123:4567 8398159 /usr/lib/libc-2.31.so - """ - - # WHEN - - with patch("builtins.open", mock_open(read_data=map_text)): - maps = list(generate_maps_for_process(1)) - - # THEN - - assert maps == [ - VirtualMap( - start=139752898736128, - end=139752898887680, - filesize=151552, - offset=0, - device="0123:4567", - flags="r--p", - inode=8398159, - path=Path("/usr/lib/libc-2.31.so"), - ), - ] - - -def test_anonymous_maps(): - # GIVEN - - map_text = """ -7f1ac1e2b000-7f1ac1e50000 r--p 00000000 08:12 8398159 - """ - - # WHEN - - with patch("builtins.open", mock_open(read_data=map_text)): - maps = list(generate_maps_for_process(1)) - - # THEN - - assert maps == [ - VirtualMap( - start=139752898736128, - end=139752898887680, - filesize=151552, - offset=0, - device="08:12", - flags="r--p", - inode=8398159, - path=None, - ), - ] - - -def test_map_permissions(): - # GIVEN - - map_text = """ -7f1ac1e2b000-7f1ac1e50000 r--- 00000000 08:12 8398159 /usr/lib/libc-2.31.so -7f1ac1e2b000-7f1ac1e50000 rw-- 00000000 08:12 8398159 /usr/lib/libc-2.31.so -7f1ac1e2b000-7f1ac1e50000 rwx- 00000000 08:12 8398159 /usr/lib/libc-2.31.so -7f1ac1e2b000-7f1ac1e50000 rwxp 00000000 08:12 8398159 /usr/lib/libc-2.31.so - """ - - # WHEN - - with patch("builtins.open", mock_open(read_data=map_text)): - maps = list(generate_maps_for_process(1)) - - # THEN - - assert maps == [ - VirtualMap( - start=139752898736128, - end=139752898887680, - filesize=151552, - offset=0, - device="08:12", - flags="r---", - inode=8398159, - path=Path("/usr/lib/libc-2.31.so"), - ), - VirtualMap( - start=139752898736128, - end=139752898887680, - filesize=151552, - offset=0, - device="08:12", - flags="rw--", - inode=8398159, - path=Path("/usr/lib/libc-2.31.so"), - ), - VirtualMap( - start=139752898736128, - end=139752898887680, - filesize=151552, - offset=0, - device="08:12", - flags="rwx-", - inode=8398159, - path=Path("/usr/lib/libc-2.31.so"), - ), - VirtualMap( - start=139752898736128, - end=139752898887680, - filesize=151552, - offset=0, - device="08:12", - flags="rwxp", - inode=8398159, - path=Path("/usr/lib/libc-2.31.so"), - ), - ] - - -def test_unexpected_line_is_ignored(): - # GIVEN - - map_text = """ -I am an unexpected line -7f1ac1e2b000-7f1ac1e50000 r--p 00000000 08:12 8398159 /usr/lib/libc-2.31.so - """ - - # WHEN - - with patch("builtins.open", mock_open(read_data=map_text)): - maps = list(generate_maps_for_process(1)) - - # THEN - - assert maps == [ - VirtualMap( - start=139752898736128, - end=139752898887680, - filesize=151552, - offset=0, - device="08:12", - flags="r--p", - inode=8398159, - path=Path("/usr/lib/libc-2.31.so"), - ), - ] - - -def test_special_maps(): - # GIVEN - - map_text = """ -555f1ab1c000-555f1ab3d000 rw-p 00000000 00:00 0 [heap] -7ffdf8102000-7ffdf8124000 rw-p 00000000 00:00 0 [stack] -7ffdf8152000-7ffdf8155000 r--p 00000000 00:00 0 [vvar] -7ffdf8155000-7ffdf8156000 r-xp 00000000 00:00 0 [vdso] -ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall] - """ - - # WHEN - - with patch("builtins.open", mock_open(read_data=map_text)): - maps = list(generate_maps_for_process(1)) - - # THEN - - assert maps == [ - VirtualMap( - start=93866958110720, - end=93866958245888, - filesize=135168, - offset=0, - device="00:00", - flags="rw-p", - inode=0, - path=Path("[heap]"), - ), - VirtualMap( - start=140728765259776, - end=140728765399040, - filesize=139264, - offset=0, - device="00:00", - flags="rw-p", - inode=0, - path=Path("[stack]"), - ), - VirtualMap( - start=140728765587456, - end=140728765599744, - filesize=12288, - offset=0, - device="00:00", - flags="r--p", - inode=0, - path=Path("[vvar]"), - ), - VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=Path("[vdso]"), - ), - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("[vsyscall]"), - ), - ] - - -def test_maps_for_binary_only_python_exec(): - # GIVEN - - python = VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=Path("the_executable"), - ) - - maps = [ - python, - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libc-2.31.so"), - ), - ] - - # WHEN - - mapinfo = parse_maps_file_for_binary(Path("the_executable"), maps) - - # THEN - - assert mapinfo.python == python - assert mapinfo.libpython is None - assert mapinfo.bss is None - assert mapinfo.heap is None - - -def test_maps_for_binary_with_heap(): - # GIVEN - - python = VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=Path("the_executable"), - ) - - heap = VirtualMap( - start=140728765587456, - end=140728765599744, - filesize=12288, - offset=0, - device="00:00", - flags="r--p", - inode=0, - path=Path("[heap]"), - ) - - maps = [ - python, - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libc-2.31.so"), - ), - heap, - ] - - # WHEN - - mapinfo = parse_maps_file_for_binary(Path("the_executable"), maps) - - # THEN - - assert mapinfo.python == python - assert mapinfo.libpython is None - assert mapinfo.bss is None - assert mapinfo.heap == heap - - -def test_maps_for_binary_with_libpython(): - # GIVEN - - python = VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=Path("the_executable"), - ) - - libpython = VirtualMap( - start=140728765587456, - end=140728765599744, - filesize=4096, - offset=0, - device="00:00", - flags="r--p", - inode=0, - path=Path("/some/path/to/libpython.so"), - ) - - maps = [ - python, - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libc-2.31.so"), - ), - libpython, - ] - - # WHEN - - mapinfo = parse_maps_file_for_binary(Path("the_executable"), maps) - - # THEN - - assert mapinfo.python == python - assert mapinfo.libpython == libpython - assert mapinfo.bss is None - assert mapinfo.heap is None - - -def test_maps_for_binary_executable_with_bss(): - # GIVEN - - python = VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=Path("the_executable"), - ) - - bss = VirtualMap( - start=139752898736128, - end=139752898887680, - filesize=4096, - offset=0, - device="08:12", - flags="r--p", - inode=8398159, - path=None, - ) - - maps = [ - python, - bss, - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libc-2.31.so"), - ), - ] - - # WHEN - - mapinfo = parse_maps_file_for_binary(Path("the_executable"), maps) - - # THEN - - assert mapinfo.python == python - assert mapinfo.libpython is None - assert mapinfo.bss == bss - assert mapinfo.heap is None - - -def test_maps_for_binary_libpython_with_bss(): - # GIVEN - - python = VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=Path("the_executable"), - ) - - bss = VirtualMap( - start=139752898736128, - end=139752898887680, - filesize=4096, - offset=0, - device="08:12", - flags="r--p", - inode=8398159, - path=None, - ) - - libpython = VirtualMap( - start=140728765587456, - end=140728765599744, - filesize=4096, - offset=0, - device="00:00", - flags="r--p", - inode=0, - path=Path("/some/path/to/libpython.so"), - ) - - libpython_bss = VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=None, - ) - - maps = [ - python, - bss, - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libc-2.31.so"), - ), - libpython, - libpython_bss, - ] - - # WHEN - - mapinfo = parse_maps_file_for_binary(Path("the_executable"), maps) - - # THEN - - assert mapinfo.python == python - assert mapinfo.libpython == libpython - assert mapinfo.bss == libpython_bss - assert mapinfo.heap is None - - -def test_maps_for_binary_libpython_without_bss(): - # GIVEN - - python = VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=Path("the_executable"), - ) - - bss = VirtualMap( - start=139752898736128, - end=139752898887680, - filesize=4096, - offset=0, - device="08:12", - flags="r--p", - inode=8398159, - path=None, - ) - - libpython = VirtualMap( - start=140728765587456, - end=140728765599744, - filesize=4096, - offset=0, - device="00:00", - flags="r--p", - inode=0, - path=Path("/some/path/to/libpython.so"), - ) - - maps = [ - python, - bss, - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libc-2.31.so"), - ), - libpython, - ] - - # WHEN - - mapinfo = parse_maps_file_for_binary(Path("the_executable"), maps) - - # THEN - - assert mapinfo.python == python - assert mapinfo.libpython == libpython - assert mapinfo.bss is None - assert mapinfo.heap is None - - -def test_maps_for_binary_libpython_with_bss_with_non_readable_segment(): - # GIVEN - - python = VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=Path("the_executable"), - ) - - bss = VirtualMap( - start=139752898736128, - end=139752898887680, - filesize=4096, - offset=0, - device="08:12", - flags="r--p", - inode=8398159, - path=None, - ) - - libpython = VirtualMap( - start=140728765587456, - end=140728765599744, - filesize=4096, - offset=0, - device="00:00", - flags="r--p", - inode=0, - path=Path("/some/path/to/libpython.so"), - ) - - libpython_bss = VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=None, - ) - - maps = [ - python, - bss, - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libc-2.31.so"), - ), - libpython, - VirtualMap( - start=1844674407369906, - end=18446744073699069, - filesize=4096, - offset=0, - device="00:00", - flags="---p", - inode=0, - path=None, - ), - libpython_bss, - ] - - # WHEN - - mapinfo = parse_maps_file_for_binary(Path("the_executable"), maps) - - # THEN - - assert mapinfo.python == python - assert mapinfo.libpython == libpython - assert mapinfo.bss == libpython_bss - assert mapinfo.heap is None - - -def test_maps_for_binary_range(): - # GIVEN - - maps = [ - VirtualMap( - start=1, - end=2, - filesize=1, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=Path("the_executable"), - ), - VirtualMap( - start=2, - end=3, - filesize=1, - offset=0, - device="08:12", - flags="r--p", - inode=8398159, - path=None, - ), - VirtualMap( - start=5, - end=6, - filesize=1, - offset=0, - device="00:00", - flags="r--p", - inode=0, - path=Path("/some/path/to/libpython.so"), - ), - VirtualMap( - start=8, - end=9, - filesize=1, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=None, - ), - ] - - # WHEN - - mapinfo = parse_maps_file_for_binary(Path("the_executable"), maps) - - # THEN - - assert mapinfo.memory.min_addr == 1 - assert mapinfo.memory.max_addr == 9 - - -def test_maps_for_binary_range_vmaps_are_ignored(): - # GIVEN - - maps = [ - VirtualMap( - start=1, - end=2, - filesize=1, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=Path("the_executable"), - ), - VirtualMap( - start=2000, - end=3000, - filesize=1000, - offset=0, - device="08:12", - flags="r--p", - inode=8398159, - path=Path("[vsso]"), - ), - VirtualMap( - start=5, - end=6, - filesize=1, - offset=0, - device="00:00", - flags="r--p", - inode=0, - path=Path("[vsyscall]"), - ), - VirtualMap( - start=8, - end=9, - filesize=1, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("[vvar]"), - ), - ] - - # WHEN - - mapinfo = parse_maps_file_for_binary(Path("the_executable"), maps) - - # THEN - - assert mapinfo.memory.min_addr == 1 - assert mapinfo.memory.max_addr == 2 - - -def test_maps_for_binary_no_binary_map(): - # GIVEN - - python = VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=Path("the_executable"), - ) - - maps = [ - python, - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libc-2.31.so"), - ), - ] - - # WHEN / THEN - - with pytest.raises(MissingExecutableMaps): - parse_maps_file_for_binary(Path("another_executable"), maps) - - -def test_maps_for_binary_no_executable_segment(): - # GIVEN - - python = VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r--p", - inode=0, - path=Path("the_executable"), - ) - - maps = [ - python, - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libc-2.31.so"), - ), - ] - - # WHEN - - mapinfo = parse_maps_file_for_binary(Path("the_executable"), maps) - - # THEN - - assert mapinfo.python == python - assert mapinfo.libpython is None - assert mapinfo.bss is None - assert mapinfo.heap is None - - -def test_maps_for_binary_multiple_libpythons(): - # GIVEN - - maps = [ - VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r--p", - inode=0, - path=Path("the_executable"), - ), - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libpython3.8.so"), - ), - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libpython2.7.so"), - ), - ] - - # WHEN / THEN - - with pytest.raises(PystackError): - parse_maps_file_for_binary(Path("the_executable"), maps) - - -def test_maps_for_binary_invalid_executable(): - # GIVEN - - python = VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=Path("the_executable"), - ) - - maps = [ - python, - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libc-2.31.so"), - ), - ] - - # WHEN - - with pytest.raises(MissingExecutableMaps, match="the_executable"): - parse_maps_file_for_binary(Path("other_executable"), maps) - - -def test_maps_for_binary_invalid_executable_and_no_available_maps(): - # GIVEN - - python = VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=None, - ) - - maps = [ - python, - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libc-2.31.so"), - ), - ] - - # WHEN - - with pytest.raises( - MissingExecutableMaps, match="There are no available executable maps" - ): - parse_maps_file_for_binary(Path("other_executable"), maps) - - -def test_maps_with_scattered_segments(): - map_text = """ -00400000-00401000 r-xp 00000000 fd:00 67488961 /bin/python3.9-dbg -00600000-00601000 r--p 00000000 fd:00 67488961 /bin/python3.9-dbg -00601000-00602000 rw-p 00001000 fd:00 67488961 /bin/python3.9-dbg -0067b000-00a58000 rw-p 00000000 00:00 0 [heap] -7f7b38000000-7f7b38028000 rw-p 00000000 00:00 0 -7f7b38028000-7f7b3c000000 ---p 00000000 00:00 0 -7f7b40000000-7f7b40021000 rw-p 00000000 00:00 0 -7f7b40021000-7f7b44000000 ---p 00000000 00:00 0 -7f7b44ec0000-7f7b44f40000 rw-p 00000000 00:00 0 -f7b45a61000-7f7b45d93000 rw-p 00000000 00:00 0 -7f7b46014000-7f7b46484000 r--p 0050b000 fd:00 1059871 /lib64/libpython3.9d.so.1.0 -7f7b46484000-7f7b46485000 ---p 00000000 00:00 0 -7f7b46485000-7f7b46cda000 rw-p 00000000 00:00 0 -7f7b46cda000-7f7b46d16000 r--p 00a3d000 fd:00 1059871 /lib64/libpython3.9d.so.1.0 -7f7b46d16000-7f7b46d6f000 rw-p 00000000 00:00 0 -7f7b46d6f000-7f7b46d92000 r--p 00001000 fd:00 67488961 /bin/python3.9-dbg -7f7b46d92000-7f7b46d93000 ---p 00000000 00:00 0 -7f7b46d93000-7f7b475d3000 rw-p 00000000 00:00 0 -7f7b498c1000-7f7b49928000 r-xp 00000000 fd:00 7023 /lib64/libssl.so.1.0.0 -7f7b49928000-7f7b49b28000 ---p 00067000 fd:00 7023 /lib64/libssl.so.1.0.0 -f7b4c632000-7f7b4c6f3000 rw-p 00000000 00:00 0 -7f7b4c6f3000-7f7b4c711000 rw-p 00000000 00:00 0 -7f7b4c711000-7f7b4c712000 r--p 0002a000 fd:00 67488961 /bin/python3.9-dbg -7f7b4c712000-7f7b4c897000 rw-p 00000000 00:00 0 -7f7b5a356000-7f7b5a35d000 r--s 00000000 fd:00 201509519 /usr/lib64/gconv/gconv-modules.cache -7f7b5a35d000-7f7b5a827000 r-xp 00000000 fd:00 1059871 /lib64/libpython3.9d.so.1.0 -7f7b5a827000-7f7b5aa27000 ---p 004ca000 fd:00 1059871 /lib64/libpython3.9d.so.1.0 -7f7b5aa27000-7f7b5aa2c000 r--p 004ca000 fd:00 1059871 /lib64/libpython3.9d.so.1.0 -7f7b5aa2c000-7f7b5aa67000 rw-p 004cf000 fd:00 1059871 /lib64/libpython3.9d.so.1.0 -7f7b5aa67000-7f7b5aa8b000 rw-p 00000000 00:00 0 -7fff26f8e000-7fff27020000 rw-p 00000000 00:00 0 [stack] -7fff27102000-7fff27106000 r--p 00000000 00:00 0 [vvar] -7fff27106000-7fff27108000 r-xp 00000000 00:00 0 [vdso] -ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall] - """ - - # WHEN - - with patch("builtins.open", mock_open(read_data=map_text)): - maps = list(generate_maps_for_process(1)) - - mapinfo = parse_maps_file_for_binary(Path("/bin/python3.9-dbg"), maps) - - # THEN - - assert mapinfo.python == VirtualMap( - start=0x400000, - end=0x401000, - filesize=4096, - offset=0, - device="fd:00", - flags="r-xp", - inode=67488961, - path=Path("/bin/python3.9-dbg"), - ) - assert mapinfo.libpython == VirtualMap( - start=0x7F7B46014000, - end=0x7F7B46484000, - filesize=4653056, - offset=5287936, - device="fd:00", - flags="r--p", - inode=1059871, - path=Path("/lib64/libpython3.9d.so.1.0"), - ) - assert mapinfo.bss == VirtualMap( - start=140167436849152, - end=140167445585920, - filesize=8736768, - offset=0, - device="00:00", - flags="rw-p", - inode=0, - path=None, - ) - assert mapinfo.heap == VirtualMap( - start=0x0067B000, - end=0x00A58000, - filesize=4050944, - offset=0, - device="00:00", - flags="rw-p", - inode=0, - path=Path("[heap]"), - ) - - -def test_get_base_map_path_existing(): - # GIVEN - maps = [ - VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=None, - ), - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=Path("/usr/lib/libc-2.31.so"), - ), - ] - - # WHEN - base_map = _get_base_map(maps) - - # THEN - assert base_map == maps[1] - - -def test_get_base_map_path_not_existing(): - # GIVEN - maps = [ - VirtualMap( - start=140728765599744, - end=140728765603840, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=None, - ), - VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=None, - ), - ] - - # WHEN - base_map = _get_base_map(maps) - - # THEN - assert base_map == maps[0] - - -def test_get_bss_base_map_no_path(): - # GIVEN - map_no_path = VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="--xp", - inode=0, - path=None, - ) - - # WHEN - with patch("pystack.maps._get_base_map", return_value=map_no_path): - bss = _get_bss("elf_maps", "load_point") - - # THEN - assert bss is None - - -def test_get_bss_no_matching_map(): - # GIVEN - libpython = VirtualMap( - start=140728765587456, - end=140728765599744, - filesize=4096, - offset=0, - device="00:00", - flags="r--p", - inode=0, - path=Path("/some/path/to/libpython.so"), - ) - - libpython_bss = VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=None, - ) - maps = [libpython, libpython_bss] - - # WHEN - with patch("pystack._pystack.get_bss_info") as mock_get_bss_info: - mock_get_bss_info.return_value = {"corrected_addr": 100000000} - bss = _get_bss(maps, libpython.start) - - # THEN - assert bss is None - - -def test_get_bss_found_matching_map(): - # GIVEN - libpython = VirtualMap( - start=140728765587456, - end=140728765599744, - filesize=4096, - offset=0, - device="00:00", - flags="r--p", - inode=0, - path=Path("/some/path/to/libpython.so"), - ) - - libpython_bss = VirtualMap( - start=18446744073699065856, - end=18446744073699069952, - filesize=4096, - offset=0, - device="00:00", - flags="r-xp", - inode=0, - path=None, - ) - maps = [libpython, libpython_bss] - - # WHEN - with patch("pystack._pystack.get_bss_info") as mock_get_bss_info: - mock_get_bss_info.return_value = { - "corrected_addr": libpython_bss.start - libpython.start, - "size": libpython_bss.filesize, - } - bss = _get_bss(maps, libpython.start) - - # THEN - assert bss == VirtualMap( - start=libpython_bss.start, - end=libpython_bss.end, - filesize=libpython_bss.filesize, - offset=libpython_bss.offset, - device="", - flags="", - inode=0, - path=None, - ) diff --git a/tests/unit/test_memory_manager.py b/tests/unit/test_memory_manager.py new file mode 100644 index 00000000..c0e9c453 --- /dev/null +++ b/tests/unit/test_memory_manager.py @@ -0,0 +1,37 @@ +import ctypes +import mmap +import os + +import pytest + +from pystack._pystack import _copy_memory_for_testing + + +def test_reads_accessible_memory_from_map_with_guard_page( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("_PYSTACK_NO_PROCESS_VM_READV", raising=False) + page_size = mmap.PAGESIZE + mapping = mmap.mmap(-1, page_size * 3) + address = ctypes.addressof(ctypes.c_char.from_buffer(mapping)) + expected = b"pthread data" + requested_address = address + page_size + mapping[page_size : page_size + len(expected)] = expected + + libc = ctypes.CDLL(None, use_errno=True) + mprotect = libc.mprotect + mprotect.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_int] + mprotect.restype = ctypes.c_int + assert mprotect(address, page_size, 0) == 0 + try: + result = _copy_memory_for_testing( + os.getpid(), + address, + address + len(mapping), + [(requested_address, len(expected)), (requested_address, len(expected))], + ) + finally: + assert mprotect(address, page_size, mmap.PROT_READ | mmap.PROT_WRITE) == 0 + mapping.close() + + assert result == [expected, expected] diff --git a/tests/unit/test_normalize_threads.py b/tests/unit/test_normalize_threads.py new file mode 100644 index 00000000..a11302f8 --- /dev/null +++ b/tests/unit/test_normalize_threads.py @@ -0,0 +1,206 @@ +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple + +from pystack._pystack import NativeReportingMode +from pystack._pystack import _normalize_threads_for_testing + +EVAL = "_PyEval_EvalFrameDefault" +PY_VERSION = (3, 13) + + +def _make_thread( + tid: int, + *, + stack_anchor: int = 0, + interpreter_id: int = 0, + native_symbols: Optional[List[str]] = None, + frames: Optional[List[Tuple[str, bool]]] = None, +) -> Dict[str, Any]: + return dict( + tid=tid, + stack_anchor=stack_anchor, + interpreter_id=interpreter_id, + native_symbols=(native_symbols or []), + frames=(frames or []), + ) + + +def test_unique_tids_pass_through_in_order(): + threads = [ + _make_thread( + 3, + native_symbols=["alpha", EVAL, "beta"], + frames=[("main", True)], + ), + _make_thread( + 1, + native_symbols=["gamma", EVAL, "delta"], + frames=[("run", True)], + ), + _make_thread( + 2, + native_symbols=["epsilon"], + frames=[("work", True)], + ), + ] + + result = _normalize_threads_for_testing( + threads, NativeReportingMode.PYTHON, PY_VERSION + ) + + assert len(result) == 3 + assert [t.tid for t in result] == [3, 1, 2] + assert [f.symbol for f in result[0].native_frames] == ["alpha", EVAL, "beta"] + assert [f.symbol for f in result[1].native_frames] == ["gamma", EVAL, "delta"] + assert [f.symbol for f in result[2].native_frames] == ["epsilon"] + assert [f.code.scope for f in result[0].frames] == ["main"] + assert [f.code.scope for f in result[1].frames] == ["run"] + assert [f.code.scope for f in result[2].frames] == ["work"] + + +def test_first_seen_tid_order_preserved(): + threads = [ + _make_thread(10, interpreter_id=0, stack_anchor=1000, frames=[("a", True)]), + _make_thread(20, interpreter_id=0, stack_anchor=2000, frames=[("b", True)]), + _make_thread(20, interpreter_id=1, stack_anchor=1500, frames=[("d", True)]), + _make_thread(10, interpreter_id=1, stack_anchor=500, frames=[("c", True)]), + ] + + result = _normalize_threads_for_testing( + threads, NativeReportingMode.OFF, PY_VERSION + ) + + assert [t.tid for t in result] == [10, 10, 20, 20] + assert [t.interpreter_id for t in result] == [0, 1, 0, 1] + assert [f.code.scope for f in result[0].frames] == ["a"] + assert [f.code.scope for f in result[1].frames] == ["c"] + assert [f.code.scope for f in result[2].frames] == ["b"] + assert [f.code.scope for f in result[3].frames] == ["d"] + + +def test_stack_anchor_sort_within_group(): + threads = [ + _make_thread(1, interpreter_id=2, stack_anchor=0, frames=[("inner", True)]), + _make_thread(1, interpreter_id=0, stack_anchor=9000, frames=[("outer", True)]), + _make_thread(1, interpreter_id=1, stack_anchor=5000, frames=[("middle", True)]), + ] + + result = _normalize_threads_for_testing( + threads, NativeReportingMode.OFF, PY_VERSION + ) + + assert len(result) == 3 + assert [t.interpreter_id for t in result] == [0, 1, 2] + assert [f.code.scope for f in result[0].frames] == ["outer"] + assert [f.code.scope for f in result[1].frames] == ["middle"] + assert [f.code.scope for f in result[2].frames] == ["inner"] + + +def test_native_slice_correctness(): + native_symbols = [ + "outer_c_func", + EVAL, + "middle_c_func_a", + "middle_c_func_b", + EVAL, + "inner_c_func", + EVAL, + ] + threads = [ + _make_thread( + 1, + interpreter_id=0, + stack_anchor=9000, + native_symbols=native_symbols, + frames=[("helper", False), ("main", True)], + ), + _make_thread( + 1, + interpreter_id=1, + stack_anchor=5000, + native_symbols=native_symbols, + frames=[("run", True)], + ), + _make_thread( + 1, + interpreter_id=2, + stack_anchor=1000, + native_symbols=native_symbols, + frames=[("work", True)], + ), + ] + + result = _normalize_threads_for_testing( + threads, NativeReportingMode.PYTHON, PY_VERSION + ) + + assert len(result) == 3 + assert result[0].interpreter_id == 0 + assert result[1].interpreter_id == 1 + assert result[2].interpreter_id == 2 + + syms0 = [f.symbol for f in result[0].native_frames] + syms1 = [f.symbol for f in result[1].native_frames] + syms2 = [f.symbol for f in result[2].native_frames] + + assert syms0 == ["outer_c_func", EVAL, "middle_c_func_a", "middle_c_func_b"] + assert syms1 == [EVAL, "inner_c_func"] + assert syms2 == [EVAL] + + assert syms0 + syms1 + syms2 == native_symbols + + assert [f.code.scope for f in result[0].frames] == ["main", "helper"] + assert [f.code.scope for f in result[1].frames] == ["run"] + assert [f.code.scope for f in result[2].frames] == ["work"] + + +def test_middle_interpreter_no_frames_gets_native_cleared(): + native_symbols = [ + "setup", + EVAL, + "bridge", + EVAL, + ] + threads = [ + _make_thread( + 1, + interpreter_id=0, + stack_anchor=9000, + native_symbols=native_symbols, + frames=[("outer", True)], + ), + _make_thread( + 1, + interpreter_id=1, + stack_anchor=5000, + native_symbols=native_symbols, + frames=[], + ), + _make_thread( + 1, + interpreter_id=2, + stack_anchor=1000, + native_symbols=native_symbols, + frames=[("inner", True)], + ), + ] + + result = _normalize_threads_for_testing( + threads, NativeReportingMode.PYTHON, PY_VERSION + ) + + assert len(result) == 3 + assert result[0].interpreter_id == 0 + assert result[1].interpreter_id == 1 + assert result[2].interpreter_id == 2 + + assert [f.symbol for f in result[0].native_frames] == ["setup", EVAL, "bridge"] + assert [f.symbol for f in result[1].native_frames] == [] + assert [f.symbol for f in result[2].native_frames] == [EVAL] + + assert [f.code.scope for f in result[0].frames] == ["outer"] + assert [f.code.scope for f in result[1].frames] == [] + assert [f.code.scope for f in result[2].frames] == ["inner"] diff --git a/tests/unit/test_process.py b/tests/unit/test_process.py deleted file mode 100644 index f33ae70e..00000000 --- a/tests/unit/test_process.py +++ /dev/null @@ -1,480 +0,0 @@ -from unittest.mock import Mock -from unittest.mock import mock_open -from unittest.mock import patch - -import pytest - -from pystack.errors import InvalidPythonProcess -from pystack.maps import VirtualMap -from pystack.process import BINARY_REGEXP -from pystack.process import LIBPYTHON_REGEXP -from pystack.process import VERSION_REGEXP -from pystack.process import get_python_version_for_core -from pystack.process import get_python_version_for_process -from pystack.process import scan_core_bss_for_python_version -from pystack.process import scan_process_bss_for_python_version - - -@pytest.mark.parametrize( - "text, version", - [ - ("libpython3.8.so", (3, 8)), - ("libpython3.5.12.so", (3, 5)), - ("libpython3.8m.so", (3, 8)), - ("libpython3.8d.so", (3, 8)), - ("libpython3.8dm.so", (3, 8)), - ("libpython2.7.so.1", (2, 7)), - ("libpython2.7.so.1.0", (2, 7)), - ("LIBPYTHON3.8.so", (3, 8)), - ("LiBpYtHoN3.6.so", (3, 6)), - ], -) -def test_libpython_detection(text, version): - # GIVEN / WHEN - result = LIBPYTHON_REGEXP.match(text) - - # THEN - assert result - - major, minor = version - assert int(result.group("major")) == major - assert int(result.group("minor")) == minor - - -@pytest.mark.parametrize( - "text", ["libpython.so", "libpython.so.1.0", "libpythondm.so.1.0"] -) -def test_libpython_false_cases(text): - # GIVEN / WHEN - result = LIBPYTHON_REGEXP.match(text) - - # THEN - assert result is None - - -@pytest.mark.parametrize( - "text, version", - [ - ("python3.8", (3, 8)), - ("python3.5.1.2", (3, 5)), - ("python2.7.exe", (2, 7)), - ("Python3.6", (3, 6)), - ("PyThOn3.5", (3, 5)), - ], -) -def test_executable_detection(text, version): - # GIVEN / WHEN - result = BINARY_REGEXP.match(text) - - # THEN - assert result - - major, minor = version - assert int(result.group("major")) == major - assert int(result.group("minor")) == minor - - -@pytest.mark.parametrize("text", ["cat3.8", "python3", "python2"]) -def test_executable_false_cases(text): - # GIVEN / WHEN - result = BINARY_REGEXP.match(text) - - # THEN - assert result is None - - -@pytest.mark.parametrize( - "text, version", - [ - ("Python 3.8.2", (3, 8)), - ("Python 3.8.1rc2", (3, 8)), - ("Python 3.9.0b4", (3, 9)), - ("Python 2.7.16", (2, 7)), - ], -) -def test_version_detection(text, version): - # GIVEN / WHEN - result = VERSION_REGEXP.match(text) - - # THEN - assert result - - major, minor = version - assert int(result.group("major")) == major - assert int(result.group("minor")) == minor - - -def test_get_python_version_for_process_fallback_bss(): - # GIVEN - mapinfo = Mock() - - # WHEN - with patch( - "pystack.process.scan_process_bss_for_python_version" - ) as scan_bss_mock, patch( - "pystack.process.LIBPYTHON_REGEXP" - ) as libpython_regexp_mock, patch( - "pystack.process.BINARY_REGEXP" - ) as binary_regexp_mock, patch( - "subprocess.check_output" - ) as subprocess_mock: - scan_bss_mock.return_value = (3, 8) - major, minor = get_python_version_for_process(0, mapinfo) - - # THEN - assert (major, minor) == (3, 8) - scan_bss_mock.assert_called_once() - libpython_regexp_mock.match.assert_not_called() - binary_regexp_mock.assert_not_called() - subprocess_mock.assert_not_called() - - -def test_get_python_version_for_process_fallback_libpython_regexp(): - # GIVEN - mapinfo = Mock() - - # WHEN - with patch( - "pystack.process.scan_process_bss_for_python_version" - ) as scan_bss_mock, patch( - "pystack.process.LIBPYTHON_REGEXP" - ) as libpython_regexp_mock, patch( - "pystack.process.BINARY_REGEXP" - ) as binary_regexp_mock, patch( - "subprocess.check_output" - ) as subprocess_mock: - scan_bss_mock.return_value = None - match = Mock() - match.group.side_effect = [3, 8] - libpython_regexp_mock.match.return_value = match - major, minor = get_python_version_for_process(0, mapinfo) - - # THEN - assert (major, minor) == (3, 8) - scan_bss_mock.assert_called_once() - libpython_regexp_mock.match.assert_called_once() - binary_regexp_mock.assert_not_called() - subprocess_mock.assert_not_called() - - -def test_get_python_version_for_process_fallback_binary_regexp(): - # GIVEN - mapinfo = Mock() - mapinfo.libpython = None - - # WHEN - with patch( - "pystack.process.scan_process_bss_for_python_version" - ) as scan_bss_mock, patch( - "pystack.process.LIBPYTHON_REGEXP" - ) as libpython_regexp_mock, patch( - "pystack.process.BINARY_REGEXP" - ) as binary_regexp_mock, patch( - "subprocess.check_output" - ) as subprocess_mock: - scan_bss_mock.return_value = None - libpython_regexp_mock.match.return_value = None - match = Mock() - match.group.side_effect = [3, 8] - binary_regexp_mock.match.return_value = match - major, minor = get_python_version_for_process(0, mapinfo) - - # THEN - assert (major, minor) == (3, 8) - scan_bss_mock.assert_called_once() - libpython_regexp_mock.match.assert_not_called() - binary_regexp_mock.match.assert_called_once() - subprocess_mock.assert_not_called() - - -def test_get_python_version_for_process_fallback_version_regexp(): - # GIVEN - mapinfo = Mock() - - # WHEN - with patch( - "pystack.process.scan_process_bss_for_python_version" - ) as scan_bss_mock, patch( - "pystack.process.LIBPYTHON_REGEXP" - ) as libpython_regexp_mock, patch( - "pystack.process.BINARY_REGEXP" - ) as binary_regexp_mock, patch( - "subprocess.check_output" - ) as subprocess_mock: - scan_bss_mock.return_value = None - libpython_regexp_mock.match.return_value = None - subprocess_mock.return_value = "Python 3.8.3" - major, minor = get_python_version_for_process(0, mapinfo) - - # THEN - assert (major, minor) == (3, 8) - scan_bss_mock.assert_called_once() - libpython_regexp_mock.match.assert_called_once() - binary_regexp_mock.match.asser_not_called() - subprocess_mock.assert_called_once() - - -def test_get_python_version_for_process_fallback_failure(): - # GIVEN - mapinfo = Mock() - - # WHEN - with patch( - "pystack.process.scan_process_bss_for_python_version" - ) as scan_bss_mock, patch( - "pystack.process.LIBPYTHON_REGEXP" - ) as libpython_regexp_mock, patch( - "pystack.process.BINARY_REGEXP" - ), patch( - "subprocess.check_output" - ) as subprocess_mock: - scan_bss_mock.return_value = None - libpython_regexp_mock.match.return_value = None - subprocess_mock.return_value = "" - # THEN - with pytest.raises(InvalidPythonProcess): - get_python_version_for_process(0, mapinfo) - - -def test_get_python_version_for_core_fallback_bss(): - # GIVEN - mapinfo = Mock() - - # WHEN - with patch( - "pystack.process.scan_core_bss_for_python_version" - ) as scan_bss_mock, patch( - "pystack.process.LIBPYTHON_REGEXP" - ) as libpython_regexp_mock, patch( - "pystack.process.BINARY_REGEXP" - ) as binary_regexp_mock, patch( - "subprocess.check_output" - ) as subprocess_mock: - scan_bss_mock.return_value = (3, 8) - major, minor = get_python_version_for_core("corefile", "executable", mapinfo) - - # THEN - assert (major, minor) == (3, 8) - scan_bss_mock.assert_called_once() - libpython_regexp_mock.match.assert_not_called() - binary_regexp_mock.assert_not_called() - subprocess_mock.assert_not_called() - - -def test_get_python_version_for_core_fallback_no_bss(): - # GIVEN - mapinfo = Mock() - mapinfo.bss = None - - # WHEN - with patch( - "pystack.process.scan_core_bss_for_python_version" - ) as scan_bss_mock, patch( - "pystack.process.LIBPYTHON_REGEXP" - ) as libpython_regexp_mock, patch( - "pystack.process.BINARY_REGEXP" - ) as binary_regexp_mock, patch( - "subprocess.check_output" - ) as subprocess_mock: - match = Mock() - match.group.side_effect = [3, 8] - libpython_regexp_mock.match.return_value = match - major, minor = get_python_version_for_core("corefile", "executable", mapinfo) - - # THEN - assert (major, minor) == (3, 8) - scan_bss_mock.assert_not_called() - libpython_regexp_mock.match.assert_called_once() - binary_regexp_mock.assert_not_called() - subprocess_mock.assert_not_called() - - -def test_get_python_version_for_core_fallback_libpython_regexp(): - # GIVEN - mapinfo = Mock() - - # WHEN - with patch( - "pystack.process.scan_core_bss_for_python_version" - ) as scan_bss_mock, patch( - "pystack.process.LIBPYTHON_REGEXP" - ) as libpython_regexp_mock, patch( - "pystack.process.BINARY_REGEXP" - ) as binary_regexp_mock, patch( - "subprocess.check_output" - ) as subprocess_mock: - scan_bss_mock.return_value = None - match = Mock() - match.group.side_effect = [3, 8] - libpython_regexp_mock.match.return_value = match - major, minor = get_python_version_for_core("corefile", "executable", mapinfo) - - # THEN - assert (major, minor) == (3, 8) - scan_bss_mock.assert_called_once() - libpython_regexp_mock.match.assert_called_once() - binary_regexp_mock.assert_not_called() - subprocess_mock.assert_not_called() - - -def test_get_python_version_for_core_fallback_binary_regexp(): - # GIVEN - mapinfo = Mock() - mapinfo.libpython = None - - # WHEN - with patch( - "pystack.process.scan_core_bss_for_python_version" - ) as scan_bss_mock, patch( - "pystack.process.LIBPYTHON_REGEXP" - ) as libpython_regexp_mock, patch( - "pystack.process.BINARY_REGEXP" - ) as binary_regexp_mock, patch( - "subprocess.check_output" - ) as subprocess_mock: - scan_bss_mock.return_value = None - libpython_regexp_mock.match.return_value = None - match = Mock() - match.group.side_effect = [3, 8] - binary_regexp_mock.match.return_value = match - major, minor = get_python_version_for_core("corefile", "executable", mapinfo) - - # THEN - assert (major, minor) == (3, 8) - scan_bss_mock.assert_called_once() - libpython_regexp_mock.match.assert_not_called() - binary_regexp_mock.match.assert_called_once() - subprocess_mock.assert_not_called() - - -def test_get_python_version_for_core_fallback_version_regexp(): - # GIVEN - mapinfo = Mock() - - # WHEN - with patch( - "pystack.process.scan_core_bss_for_python_version" - ) as scan_bss_mock, patch( - "pystack.process.LIBPYTHON_REGEXP" - ) as libpython_regexp_mock, patch( - "pystack.process.BINARY_REGEXP" - ) as binary_regexp_mock, patch( - "subprocess.check_output" - ) as subprocess_mock: - scan_bss_mock.return_value = None - libpython_regexp_mock.match.return_value = None - subprocess_mock.return_value = "Python 3.8.3" - major, minor = get_python_version_for_core("corefile", "executable", mapinfo) - - # THEN - assert (major, minor) == (3, 8) - scan_bss_mock.assert_called_once() - libpython_regexp_mock.match.assert_called_once() - binary_regexp_mock.match.asser_not_called() - subprocess_mock.assert_called_once() - - -def test_get_python_version_for_core_fallback_falure(): - # GIVEN - mapinfo = Mock() - - # WHEN - with patch( - "pystack.process.scan_core_bss_for_python_version" - ) as scan_bss_mock, patch( - "pystack.process.LIBPYTHON_REGEXP" - ) as libpython_regexp_mock, patch( - "pystack.process.BINARY_REGEXP" - ), patch( - "subprocess.check_output" - ) as subprocess_mock: - scan_bss_mock.return_value = None - libpython_regexp_mock.match.return_value = None - subprocess_mock.return_value = "" - # THEN - with pytest.raises(InvalidPythonProcess): - get_python_version_for_core("corefile", "executable", mapinfo) - - -def test_scan_process_bss_for_python_version(): - # GIVEM - - memory = ( - b"garbagegarbagePython 3.8.3 (default, May 22 2020, 23:30:25)garbagegarbage" - ) - bss = Mock() - # WHEN - - with patch("pystack._pystack.copy_memory_from_address", return_value=memory): - major, minor = scan_process_bss_for_python_version(0, bss) - - # THEN - - assert major == 3 - assert minor == 8 - - -def test_scan_process_bss_for_python_version_failure(): - # GIVEM - - memory = b"garbagegarbagegarbagegarbage" - bss = Mock() - # WHEN - - with patch("pystack._pystack.copy_memory_from_address", return_value=memory): - result = scan_process_bss_for_python_version(0, bss) - - # THEN - - assert result is None - - -def test_scan_core_bss_for_python_version(): - # GIVEM - - memory = ( - b"garbagegarbagePython 3.8.3 (default, May 22 2020, 23:30:25)garbagegarbage" - ) - bss = VirtualMap( - start=0, - end=len(memory), - filesize=len(memory), - offset=0, - flags="", - inode=0, - device="", - path=None, - ) - # WHEN - - with patch("builtins.open", mock_open(read_data=memory)): - major, minor = scan_core_bss_for_python_version("corefile", bss) - - # THEN - - assert major == 3 - assert minor == 8 - - -def test_scan_core_bss_for_python_version_failure(): - # GIVEM - - memory = b"garbagegarbagegarbagegarbage" - bss = VirtualMap( - start=0, - end=len(memory), - filesize=len(memory), - offset=0, - flags="", - inode=0, - device="", - path=None, - ) - # WHEN - - with patch("builtins.open", mock_open(read_data=memory)): - result = scan_core_bss_for_python_version("corefile", bss) - - # THEN - - assert result is None diff --git a/tests/unit/test_traceback_formatter.py b/tests/unit/test_traceback_formatter.py index 62d628ee..253da4f8 100644 --- a/tests/unit/test_traceback_formatter.py +++ b/tests/unit/test_traceback_formatter.py @@ -3,8 +3,9 @@ import pytest +from pystack.engine import NativeReportingMode from pystack.traceback_formatter import format_thread -from pystack.traceback_formatter import print_thread +from pystack.traceback_formatter import print_threads from pystack.types import SYMBOL_IGNORELIST from pystack.types import LocationInfo from pystack.types import NativeFrame @@ -48,6 +49,7 @@ def test_traceback_formatter_no_native(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) native_frames = [ @@ -71,7 +73,7 @@ def test_traceback_formatter_no_native(): # WHEN - lines = list(format_thread(thread, native=False)) + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN @@ -80,7 +82,6 @@ def test_traceback_formatter_no_native(): ' (Python) File "file1.py", line 1, in function1', ' (Python) File "file2.py", line 2, in function2', ' (Python) File "file3.py", line 3, in function3', - "", ] @@ -98,7 +99,7 @@ def test_traceback_formatter_no_frames_no_native(): # WHEN - lines = list(format_thread(thread, native=False)) + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN @@ -126,7 +127,7 @@ def test_traceback_formatter_no_frames_native(): # WHEN - lines = list(format_thread(thread, native=True)) + lines = list(format_thread(thread, NativeReportingMode.ALL)) # THEN assert lines == [ @@ -135,7 +136,6 @@ def test_traceback_formatter_no_frames_native(): ' (C) File "native_file2.c", line 2, in native_function2 (library.so)', ' (C) File "native_file3.c", line 3, in native_function3 (library.so)', ' (C) File "native_file4.c", line 4, in native_function4 (library.so)', - "", ] @@ -163,13 +163,12 @@ def test_traceback_formatter_no_frames_native_with_eval_frames(): # WHEN - lines = list(format_thread(thread, native=True)) + lines = list(format_thread(thread, NativeReportingMode.ALL)) # THEN assert lines == [ "Traceback for thread 1 [] (most recent call last):", "* - Unable to merge native stack due to insufficient native information - *", - "", ] @@ -203,6 +202,7 @@ def test_traceback_formatter_no_mergeable_native_frames(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) native_frames = [ @@ -225,7 +225,7 @@ def test_traceback_formatter_no_mergeable_native_frames(): # WHEN - lines = list(format_thread(thread, native=True)) + lines = list(format_thread(thread, NativeReportingMode.ALL)) # THEN assert lines == [ @@ -234,7 +234,52 @@ def test_traceback_formatter_no_mergeable_native_frames(): ' (Python) File "file1.py", line 1, in function1', ' (Python) File "file2.py", line 2, in function2', ' (Python) File "file3.py", line 3, in function3', - "", + ] + + +def test_traceback_formatter_with_source_lineno_out_of_range(): + # GIVEN + + frame = PyFrame( + prev=None, + next=None, + code=PyCodeObject( + filename="template.html", + scope="root", + location=LocationInfo(13, 13, 0, 0), + ), + arguments={"arg": "value"}, + locals={"local": "value"}, + is_entry=True, + is_shim=False, + ) + + thread = PyThread( + tid=1, + frame=frame, + native_frames=[], + holds_the_gil=False, + is_gc_collecting=False, + python_version=(3, 8), + ) + + # WHEN + source_data = "" + with ( + patch("builtins.open", mock_open(read_data=source_data)), + patch("os.path.exists", return_value=True), + ): + lines = list(format_thread(thread, NativeReportingMode.OFF)) + + # THEN + + assert lines == [ + "Traceback for thread 1 [] (most recent call last):", + ' (Python) File "template.html", line 13, in root', + " Arguments:", + " arg: value", + " Locals:", + " local: value", ] @@ -268,6 +313,7 @@ def test_traceback_formatter_with_source(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) native_frames = [ @@ -291,10 +337,11 @@ def test_traceback_formatter_with_source(): # WHEN source_data = "\n".join(f'x = "This is the line {line}"' for line in range(1, 5)) - with patch("builtins.open", mock_open(read_data=source_data)), patch( - "os.path.exists", return_value=True + with ( + patch("builtins.open", mock_open(read_data=source_data)), + patch("os.path.exists", return_value=True), ): - lines = list(format_thread(thread, native=False)) + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN @@ -306,7 +353,6 @@ def test_traceback_formatter_with_source(): ' x = "This is the line 2"', ' (Python) File "file3.py", line 3, in function3', ' x = "This is the line 3"', - "", ] @@ -340,6 +386,7 @@ def test_traceback_formatter_native_matching_simple_eval_frames(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) native_frames = [ @@ -369,7 +416,7 @@ def test_traceback_formatter_native_matching_simple_eval_frames(): # WHEN - lines = list(format_thread(thread, native=True)) + lines = list(format_thread(thread, NativeReportingMode.ALL)) # THEN @@ -382,7 +429,6 @@ def test_traceback_formatter_native_matching_simple_eval_frames(): ' (C) File "native_file3.c", line 3, in native_function3 (library.so)', ' (Python) File "file3.py", line 3, in function3', ' (C) File "native_file4.c", line 4, in native_function4 (library.so)', - "", ] @@ -416,6 +462,7 @@ def test_traceback_formatter_native_matching_composite_eval_frames(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) native_frames = [ @@ -457,7 +504,7 @@ def test_traceback_formatter_native_matching_composite_eval_frames(): # WHEN - lines = list(format_thread(thread, native=True)) + lines = list(format_thread(thread, NativeReportingMode.ALL)) # THEN @@ -470,7 +517,6 @@ def test_traceback_formatter_native_matching_composite_eval_frames(): ' (C) File "native_file3.c", line 3, in native_function3 (library.so)', ' (Python) File "file3.py", line 3, in function3', ' (C) File "native_file4.c", line 4, in native_function4 (library.so)', - "", ] @@ -509,6 +555,7 @@ def test_traceback_formatter_native_matching_eval_frames_ignore_frames(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) ignorelist_frames = [ @@ -571,7 +618,7 @@ def test_traceback_formatter_native_matching_eval_frames_ignore_frames(): # WHEN - lines = list(format_thread(thread, native=True)) + lines = list(format_thread(thread, NativeReportingMode.ALL)) # THEN @@ -586,7 +633,6 @@ def test_traceback_formatter_native_matching_eval_frames_ignore_frames(): ' (C) File "native_file4.c", line 4, in native_function4 (library.so)', ' (Python) File "file4.py", line 4, in function4', ' (C) File "native_file5.c", line 5, in native_function5 (library.so)', - "", ] @@ -604,6 +650,7 @@ def test_traceback_formatter_gil_detection(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) thread = PyThread( tid=1, @@ -616,14 +663,13 @@ def test_traceback_formatter_gil_detection(): # WHEN - lines = list(format_thread(thread, native=False)) + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN assert lines == [ "Traceback for thread 1 [Has the GIL] (most recent call last):", ' (Python) File "file1.py", line 1, in function1', - "", ] @@ -641,6 +687,7 @@ def test_traceback_formatter_gc_detection_with_native(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) thread = PyThread( tid=1, @@ -655,14 +702,13 @@ def test_traceback_formatter_gc_detection_with_native(): # WHEN - lines = list(format_thread(thread, native=False)) + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN assert lines == [ "Traceback for thread 1 [Garbage collecting] (most recent call last):", ' (Python) File "file1.py", line 1, in function1', - "", ] @@ -680,6 +726,7 @@ def test_traceback_formatter_gc_detection_without_native(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) thread = PyThread( tid=1, @@ -692,14 +739,13 @@ def test_traceback_formatter_gc_detection_without_native(): # WHEN - lines = list(format_thread(thread, native=False)) + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN assert lines == [ "Traceback for thread 1 [Has the GIL,Garbage collecting] (most recent call last):", ' (Python) File "file1.py", line 1, in function1', - "", ] @@ -716,6 +762,7 @@ def test_traceback_formatter_dropping_the_gil_detection(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) native_frames = [ NativeFrame(0x0, "native_function1", "native_file1.c", 1, 0, "library.so"), @@ -738,14 +785,13 @@ def test_traceback_formatter_dropping_the_gil_detection(): # WHEN - lines = list(format_thread(thread, native=False)) + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN assert lines == [ "Traceback for thread 1 [Dropping the GIL] (most recent call last):", ' (Python) File "file1.py", line 1, in function1', - "", ] @@ -762,6 +808,7 @@ def test_traceback_formatter_taking_the_gil_detection(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) native_frames = [ NativeFrame(0x0, "native_function1", "native_file1.c", 1, 0, "library.so"), @@ -784,14 +831,13 @@ def test_traceback_formatter_taking_the_gil_detection(): # WHEN - lines = list(format_thread(thread, native=False)) + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN assert lines == [ "Traceback for thread 1 [Waiting for the GIL] (most recent call last):", ' (Python) File "file1.py", line 1, in function1', - "", ] @@ -825,6 +871,7 @@ def test_traceback_formatter_native_not_matching_simple_eval_frames(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) native_frames = [ @@ -846,7 +893,7 @@ def test_traceback_formatter_native_not_matching_simple_eval_frames(): # WHEN - lines = list(format_thread(thread, native=False)) + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN @@ -855,7 +902,6 @@ def test_traceback_formatter_native_not_matching_simple_eval_frames(): ' (Python) File "file1.py", line 1, in function1', ' (Python) File "file2.py", line 2, in function2', ' (Python) File "file3.py", line 3, in function3', - "", ] @@ -889,6 +935,7 @@ def test_traceback_formatter_native_not_matching_composite_eval_frames(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) native_frames = [ @@ -923,7 +970,7 @@ def test_traceback_formatter_native_not_matching_composite_eval_frames(): # WHEN - lines = list(format_thread(thread, native=False)) + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN @@ -932,7 +979,6 @@ def test_traceback_formatter_native_not_matching_composite_eval_frames(): ' (Python) File "file1.py", line 1, in function1', ' (Python) File "file2.py", line 2, in function2', ' (Python) File "file3.py", line 3, in function3', - "", ] @@ -977,6 +1023,7 @@ def test_traceback_formatter_mixed_inlined_frames(): arguments={}, locals={}, is_entry=code.scope in entry_funcs, + is_shim=False, ) native_frames = [ @@ -1004,7 +1051,7 @@ def test_traceback_formatter_mixed_inlined_frames(): # WHEN - lines = list(format_thread(thread, native=True)) + lines = list(format_thread(thread, NativeReportingMode.ALL)) # THEN @@ -1018,7 +1065,6 @@ def test_traceback_formatter_mixed_inlined_frames(): ' (Python) File "file4.py", line 4, in function4', ' (Python) File "file5.py", line 5, in function5', ' (C) File "native_file3.c", line 3, in native_function3 (library.so)', - "", ] assert lines == expected_lines @@ -1063,6 +1109,7 @@ def test_traceback_formatter_all_inlined_frames(): arguments={}, locals={}, is_entry=False, + is_shim=False, ) current_frame.is_entry = True @@ -1086,7 +1133,7 @@ def test_traceback_formatter_all_inlined_frames(): # WHEN - lines = list(format_thread(thread, native=True)) + lines = list(format_thread(thread, NativeReportingMode.ALL)) # THEN @@ -1099,7 +1146,89 @@ def test_traceback_formatter_all_inlined_frames(): ' (Python) File "file4.py", line 4, in function4', ' (Python) File "file5.py", line 5, in function5', ' (C) File "native_file3.c", line 3, in native_function3 (library.so)', - "", + ] + assert lines == expected_lines + + +def test_traceback_formatter_native_last(): + # GIVEN + + codes = [ + PyCodeObject( + filename="file1.py", + scope="function1", + location=LocationInfo(1, 1, 0, 0), + ), + PyCodeObject( + filename="file2.py", + scope="function2", + location=LocationInfo(2, 2, 0, 0), + ), + PyCodeObject( + filename="file3.py", + scope="function3", + location=LocationInfo(3, 3, 0, 0), + ), + PyCodeObject( + filename="file4.py", + scope="function4", + location=LocationInfo(4, 4, 0, 0), + ), + PyCodeObject( + filename="file5.py", + scope="function5", + location=LocationInfo(5, 5, 0, 0), + ), + ] + + current_frame = None + for code in reversed(codes): + current_frame = PyFrame( + prev=None, + next=current_frame, + code=code, + arguments={}, + locals={}, + is_entry=False, + is_shim=False, + ) + current_frame.is_entry = True + + native_frames = [ + NativeFrame(0x0, "native_function1", "native_file1.c", 1, 0, "library.so"), + NativeFrame(0x1, "PyEval_EvalFrameEx", "Python/ceval.c", 123, 0, "library.so"), + NativeFrame(0x3, "native_function2", "native_file2.c", 2, 0, "library.so"), + NativeFrame( + 0x2, "_PyEval_EvalFrameDefault", "Python/ceval.c", 12, 0, "library.so" + ), + NativeFrame(0x4, "native_function3", "native_file3.c", 3, 0, "library.so"), + NativeFrame(0x6, "native_function4", "native_file4.c", 4, 0, "library.so"), + ] + + thread = PyThread( + tid=1, + frame=current_frame, + native_frames=native_frames, + holds_the_gil=False, + is_gc_collecting=False, + python_version=(3, 8), + ) + + # WHEN + + lines = list(format_thread(thread, NativeReportingMode.LAST)) + + # THEN + + expected_lines = [ + "Traceback for thread 1 [] (most recent call last):", + ' (Python) File "file1.py", line 1, in function1', + ' (Python) File "file2.py", line 2, in function2', + ' (Python) File "file3.py", line 3, in function3', + ' (Python) File "file4.py", line 4, in function4', + ' (Python) File "file5.py", line 5, in function5', + ' (C) File "native_file3.c", line 3, in native_function3 (library.so)', + ' (C) File "native_file4.c", line 4, in native_function4 (library.so)', ] assert lines == expected_lines @@ -1120,7 +1249,7 @@ def test_print_thread(capsys): "pystack.traceback_formatter.format_thread", return_value=("1", "2", "3"), ): - print_thread(thread, native=False) + print_threads([thread], NativeReportingMode.OFF) # THEN @@ -1187,6 +1316,7 @@ def test_traceback_formatter_locals( arguments=arguments, locals=locals, is_entry=True, + is_shim=False, ) thread = PyThread( @@ -1203,18 +1333,23 @@ def test_traceback_formatter_locals( source_data = "\n".join( f'x = "This is the line {line}" or (1+1)' for line in range(1, 5) ) - with patch("builtins.open", mock_open(read_data=source_data)), patch( - "os.path.exists", return_value=True + with ( + patch("builtins.open", mock_open(read_data=source_data)), + patch("os.path.exists", return_value=True), ): - lines = list(format_thread(thread, native=False)) + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN print(lines) - assert lines == [ - "Traceback for thread 1 [] (most recent call last):", - ' (Python) File "file1.py", line 1, in function1', - ' x = "This is the line 1" or (1+1)', - ] + expected_locals_render + [""] + assert ( + lines + == [ + "Traceback for thread 1 [] (most recent call last):", + ' (Python) File "file1.py", line 1, in function1', + ' x = "This is the line 1" or (1+1)', + ] + + expected_locals_render + ) def test_traceback_formatter_thread_names(): @@ -1230,6 +1365,7 @@ def test_traceback_formatter_thread_names(): arguments=[], locals=[], is_entry=True, + is_shim=False, ) thread = PyThread( @@ -1244,14 +1380,14 @@ def test_traceback_formatter_thread_names(): # WHEN - lines = list(format_thread(thread, native=False)) + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN print(lines) assert lines == [ "Traceback for thread 1 (foo) [] (most recent call last):", ' (Python) File "file1.py", line 1, in function1', - ] + [""] + ] def test_traceback_formatter_position_infomation(): @@ -1284,6 +1420,7 @@ def test_traceback_formatter_position_infomation(): arguments={}, locals={}, is_entry=True, + is_shim=False, ) thread = PyThread( @@ -1299,13 +1436,15 @@ def test_traceback_formatter_position_infomation(): source_data = "\n".join( f'x = "This is the line {line}" or (1+1)' for line in range(1, 5) ) - with patch("builtins.open", mock_open(read_data=source_data)), patch( - "os.path.exists", return_value=True - ), patch( - "pystack.traceback_formatter.colored", - side_effect=lambda x, *args, **kwargs: x, - ) as colored_mock: - lines = list(format_thread(thread, native=False)) + with ( + patch("builtins.open", mock_open(read_data=source_data)), + patch("os.path.exists", return_value=True), + patch( + "pystack.traceback_formatter.colored", + side_effect=lambda x, *args, **kwargs: x, + ) as colored_mock, + ): + lines = list(format_thread(thread, NativeReportingMode.OFF)) # THEN @@ -1317,8 +1456,211 @@ def test_traceback_formatter_position_infomation(): ' x = "This is the line 2" or (1+1)', ' (Python) File "file3.py", line 3, in function3', ' x = "This is the line 3" or (1+1)', - "", ] colored_mock.assert_any_call("x =", color="blue") colored_mock.assert_any_call('"This is the line 2" ', color="blue") colored_mock.assert_any_call("(1+1)", color="blue") + + +def test_shim_frames_are_ignored(): + # GIVEN + + codes = [ + PyCodeObject( + filename="file1.py", + scope="function1", + location=LocationInfo(1, 1, 0, 3), + ), + PyCodeObject( + filename="", + scope="", + location=LocationInfo(0, 0, 0, 0), + ), + PyCodeObject( + filename="file2.py", + scope="function2", + location=LocationInfo(2, 2, 4, 25), + ), + PyCodeObject( + filename="", + scope="", + location=LocationInfo(0, 0, 0, 0), + ), + PyCodeObject( + filename="file3.py", + scope="function3", + location=LocationInfo(3, 3, 28, 33), + ), + PyCodeObject( + filename="", + scope="", + location=LocationInfo(0, 0, 0, 0), + ), + PyCodeObject( + filename="file4.py", + scope="function4", + location=LocationInfo(4, 4, 60, 45), + ), + ] + + current_frame = None + for code in reversed(codes): + current_frame = PyFrame( + prev=None, + next=current_frame, + code=code, + arguments={}, + locals={}, + is_entry=True, + is_shim=code.scope == "", + ) + + thread = PyThread( + tid=1, + frame=current_frame, + native_frames=[], + holds_the_gil=False, + is_gc_collecting=False, + python_version=(3, 8), + ) + + # WHEN + source_data = "\n".join( + f'x = "This is the line {line}" or (1+1)' for line in range(1, 5) + ) + with ( + patch("builtins.open", mock_open(read_data=source_data)), + patch("os.path.exists", return_value=True), + patch( + "pystack.traceback_formatter.colored", + side_effect=lambda x, *args, **kwargs: x, + ) as colored_mock, + ): + lines = list(format_thread(thread, NativeReportingMode.OFF)) + + # THEN + + assert lines == [ + "Traceback for thread 1 [] (most recent call last):", + ' (Python) File "file1.py", line 1, in function1', + ' x = "This is the line 1" or (1+1)', + ' (Python) File "file2.py", line 2, in function2', + ' x = "This is the line 2" or (1+1)', + ' (Python) File "file3.py", line 3, in function3', + ' x = "This is the line 3" or (1+1)', + ' (Python) File "file4.py", line 4, in function4', + ' x = "This is the line 4" or (1+1)', + ] + colored_mock.assert_any_call("x =", color="blue") + colored_mock.assert_any_call('"This is the line 2" ', color="blue") + colored_mock.assert_any_call("(1+1)", color="blue") + + +def test_native_traceback_with_shim_frames(): + # GIVEN + + codes = [ + PyCodeObject( + filename="", + scope="", + location=LocationInfo(0, 0, 0, 0), + ), + PyCodeObject( + filename="file1.py", + scope="function1", + location=LocationInfo(1, 1, 0, 3), + ), + PyCodeObject( + filename="", + scope="", + location=LocationInfo(0, 0, 0, 0), + ), + PyCodeObject( + filename="file2.py", + scope="function2", + location=LocationInfo(2, 2, 4, 25), + ), + PyCodeObject( + filename="", + scope="", + location=LocationInfo(0, 0, 0, 0), + ), + PyCodeObject( + filename="file3.py", + scope="function3", + location=LocationInfo(3, 3, 28, 33), + ), + ] + + current_frame = None + for code in reversed(codes): + current_frame = PyFrame( + prev=None, + next=current_frame, + code=code, + arguments={}, + locals={}, + is_entry=code.scope != "", + is_shim=code.scope == "", + ) + + native_frames = [ + NativeFrame(0x0, "native_function1", "native_file1.c", 1, 0, "library.so"), + NativeFrame( + 0x1, "_PyEval_EvalFrameDefault", "Python/ceval.c", 123, 0, "library.so" + ), + NativeFrame(0x2, "native_function2", "native_file2.c", 2, 0, "library.so"), + NativeFrame( + 0x3, "_PyEval_EvalFrameDefault", "Python/ceval.c", 123, 0, "library.so" + ), + NativeFrame(0x4, "native_function3", "native_file3.c", 3, 0, "library.so"), + NativeFrame( + 0x5, "_PyEval_EvalFrameDefault", "Python/ceval.c", 123, 0, "library.so" + ), + NativeFrame(0x6, "native_function4", "native_file4.c", 4, 0, "library.so"), + ] + + thread = PyThread( + tid=1, + frame=current_frame, + native_frames=native_frames, + holds_the_gil=False, + is_gc_collecting=False, + python_version=(3, 8), + ) + + # WHEN + source_data = "\n".join( + f'x = "This is the line {line}" or (1+1)' for line in range(1, 5) + ) + with ( + patch("builtins.open", mock_open(read_data=source_data)), + patch("os.path.exists", return_value=True), + patch( + "pystack.traceback_formatter.colored", + side_effect=lambda x, *args, **kwargs: x, + ) as colored_mock, + ): + lines = list(format_thread(thread, NativeReportingMode.PYTHON)) + + # THEN + + assert lines == [ + "Traceback for thread 1 [] (most recent call last):", + ' (C) File "native_file1.c", line 1, in native_function1 (library.so)', + ' (Python) File "file1.py", line 1, in function1', + ' x = "This is the line 1" or (1+1)', + ' (Python) File "", line 0, in ', + ' (C) File "native_file2.c", line 2, in native_function2 (library.so)', + ' (Python) File "file2.py", line 2, in function2', + ' x = "This is the line 2" or (1+1)', + ' (Python) File "", line 0, in ', + ' (C) File "native_file3.c", line 3, in native_function3 (library.so)', + ' (Python) File "file3.py", line 3, in function3', + ' x = "This is the line 3" or (1+1)', + ' (C) File "native_file4.c", line 4, in native_function4 (library.so)', + ] + + colored_mock.assert_any_call("x =", color="blue") + colored_mock.assert_any_call('"This is the line 2" ', color="blue") + colored_mock.assert_any_call("(1+1)", color="blue") diff --git a/tests/utils.py b/tests/utils.py index 67a9ff88..f39ab44a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -6,6 +6,7 @@ import shutil import subprocess import sys +import textwrap import time from typing import Generator from typing import Iterable @@ -22,6 +23,12 @@ PythonVersion = Tuple[Tuple[int, int], pathlib.Path] ALL_VERSIONS = [ + ((3, 15), "python3.15t"), + ((3, 15), "python3.15"), + ((3, 14), "python3.14t"), + ((3, 14), "python3.14"), + ((3, 13), "python3.13t"), + ((3, 13), "python3.13"), ((3, 12), "python3.12"), ((3, 11), "python3.11"), ((3, 10), "python3.10"), @@ -32,7 +39,9 @@ ((2, 7), "python2.7"), ] -Interpreter = collections.namedtuple("Interpreter", "version path has_symbols") +Interpreter = collections.namedtuple( + "Interpreter", "version path has_symbols uses_musl" +) def find_all_available_pythons() -> Iterable[Interpreter]: # pragma: no cover @@ -42,6 +51,8 @@ def find_all_available_pythons() -> Iterable[Interpreter]: # pragma: no cover versions = [((sys.version_info[0], sys.version_info[1]), sys.executable)] elif test_version is not None: major, minor = test_version.split(".") + if minor.endswith("t"): + minor = minor[:-1] versions = [((int(major), int(minor)), f"python{test_version}")] else: versions = ALL_VERSIONS @@ -70,8 +81,8 @@ def find_all_available_pythons() -> Iterable[Interpreter]: # pragma: no cover has_symbols = result.returncode == 0 and ( b" stripped" not in result.stdout or b"not stripped" in result.stdout ) - - yield Interpreter(version, pathlib.Path(location), has_symbols) + uses_musl = b"musl" in result.stdout + yield Interpreter(version, pathlib.Path(location), has_symbols, uses_musl) AVAILABLE_PYTHONS = tuple(find_all_available_pythons()) @@ -84,7 +95,7 @@ def spawn_child_process( fifo = tmpdir / "the_fifo" os.mkfifo(fifo) with subprocess.Popen( - [python, test_file, str(fifo)], + [python, "-S", test_file, str(fifo)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -105,12 +116,15 @@ def spawn_child_process( @contextlib.contextmanager def generate_core_file( - python: pathlib.Path, test_file: pathlib.Path, tmpdir: pathlib.Path + python: pathlib.Path, + test_file: pathlib.Path, + tmpdir: pathlib.Path, + disable_site: bool = True, ) -> Generator[pathlib.Path, None, None]: fifo = tmpdir / "the_fifo" os.mkfifo(fifo) with subprocess.Popen( - [python, test_file, str(fifo)], + [python, *(["-S"] if disable_site else []), test_file, str(fifo)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) as process: @@ -136,6 +150,36 @@ def generate_core_file( process.wait(timeout=TIMEOUT) +@contextlib.contextmanager +def generate_core_after_crash( + python: pathlib.Path, + test_file: pathlib.Path, + tmpdir: pathlib.Path, +) -> Generator[pathlib.Path, None, None]: + gdb_commands = textwrap.dedent(f""" + set pagination off + run + generate-core-file {tmpdir}/core.%p + quit + """) + + gdb_script = tmpdir / "gdb_script.txt" + with open(gdb_script, "w") as f: + f.write(gdb_commands) + + subprocess.run( + ["gdb", "-x", str(gdb_script), "-batch", "--args", str(python), str(test_file)], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=TIMEOUT, + text=True, + ) + + (core,) = pathlib.Path(tmpdir).glob("core.*") + yield core + + @contextlib.contextmanager def xfail_on_expected_exceptions( method: StackMethod, @@ -181,6 +225,7 @@ def generate_all_pystack_combinations( ]: # pragma: no cover if corefile: stack_methods = ( + StackMethod.DEBUG_OFFSETS, StackMethod.SYMBOLS, StackMethod.BSS, StackMethod.ELF_DATA, @@ -188,6 +233,7 @@ def generate_all_pystack_combinations( ) else: stack_methods = ( + StackMethod.DEBUG_OFFSETS, StackMethod.SYMBOLS, StackMethod.BSS, StackMethod.HEAP, @@ -205,7 +251,11 @@ def generate_all_pystack_combinations( blocking_methods, AVAILABLE_PYTHONS, ): - (major_version, minor_version) = python.version + major_version, minor_version = python.version + if method == StackMethod.DEBUG_OFFSETS and ( + major_version < 3 or (major_version == 3 and minor_version < 13) + ): + continue if method == StackMethod.BSS and ( major_version > 3 or (major_version == 3 and minor_version >= 10) ): @@ -267,3 +317,26 @@ def all_pystack_combinations(corefile=False, native=False): [python[:2] for python in AVAILABLE_PYTHONS if python.version < (3, 10)], ids=[python[1].name for python in AVAILABLE_PYTHONS if python.version < (3, 10)], ) + + +ALL_PYTHONS_THAT_SUPPORT_SUBINTERPRETERS = pytest.mark.parametrize( + "python", + [python[:2] for python in AVAILABLE_PYTHONS if python.version >= (3, 11)], + ids=[python[1].name for python in AVAILABLE_PYTHONS if python.version >= (3, 11)], +) + + +def all_pythons_since(major: int, minor: int): + return pytest.mark.parametrize( + "python", + [ + python[:2] + for python in AVAILABLE_PYTHONS + if python.version >= (major, minor) + ], + ids=[ + python[1].name + for python in AVAILABLE_PYTHONS + if python.version >= (major, minor) + ], + )